waveform-analysis 0.1.71 → 0.1.73

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 +260 -40
  2. package/dist/assets/{workerRuntime-o5An0M-W.js → workerRuntime-5p-EI-GL.js} +1 -1
  3. package/dist/index.cjs +2 -1
  4. package/dist/index.js +3874 -2778
  5. package/dist/style.css +1 -1
  6. package/dist/types/components/WaveformChart.vue.d.ts +24 -6
  7. package/dist/types/components/annotation/WaveformAnnotationLayer.vue.d.ts +2 -0
  8. package/dist/types/components/annotation/useWaveformChartAnnotations.d.ts +0 -1
  9. package/dist/types/components/controls/WaveformToolbar.vue.d.ts +7 -0
  10. package/dist/types/components/controls/chartExportOptions.d.ts +5 -0
  11. package/dist/types/components/controls/chartSnapshot.d.ts +2 -0
  12. package/dist/types/components/controls/snapshotTitle.d.ts +2 -0
  13. package/dist/types/components/controls/toolbarRegistry.d.ts +21 -0
  14. package/dist/types/components/controls/useChartCommands.d.ts +42 -0
  15. package/dist/types/components/controls/useChartImageExport.d.ts +17 -0
  16. package/dist/types/components/controls/useControlMode.d.ts +8 -0
  17. package/dist/types/components/core/latestTaskScheduler.d.ts +1 -1
  18. package/dist/types/components/core/layout.d.ts +6 -3
  19. package/dist/types/components/core/niceScale.d.ts +34 -0
  20. package/dist/types/components/core/trackLayoutBuilder.d.ts +4 -1
  21. package/dist/types/components/core/trackPagination.d.ts +1 -1
  22. package/dist/types/components/core/types.d.ts +5 -0
  23. package/dist/types/components/core/useWaveformChartController.d.ts +24 -6
  24. package/dist/types/components/core/useWaveformChartLifecycle.d.ts +2 -2
  25. package/dist/types/components/core/useWaveformData.d.ts +2 -0
  26. package/dist/types/components/core/useWaveformLayout.d.ts +4 -1
  27. package/dist/types/components/core/useWaveformMeasurements.d.ts +2 -0
  28. package/dist/types/components/core/useWaveformRenderSampling.d.ts +1 -0
  29. package/dist/types/components/core/useWaveformViewportRestore.d.ts +5 -0
  30. package/dist/types/components/core/waveformChartTypes.d.ts +5 -0
  31. package/dist/types/components/core/waveformSamplingSession.d.ts +24 -0
  32. package/dist/types/components/core/yAxisLabelStyle.d.ts +3 -0
  33. package/dist/types/components/core/yAxisLayoutMetrics.d.ts +2 -1
  34. package/dist/types/components/core/yAxisPadding.d.ts +7 -0
  35. package/dist/types/components/core/yAxisTitleLayout.d.ts +2 -0
  36. package/dist/types/components/interaction/useExtremaTickViewport.d.ts +5 -0
  37. package/dist/types/components/interaction/useWaveformViewport.d.ts +1 -0
  38. package/dist/types/components/rendering/WaveformLegend.vue.d.ts +2 -2
  39. package/dist/types/components/rendering/WaveformTrack.vue.d.ts +2 -3
  40. package/dist/types/components/rendering/WaveformTrackAxes.vue.d.ts +1 -1
  41. package/dist/types/components/rendering/WaveformTrackBackdrop.vue.d.ts +1 -0
  42. package/dist/types/components/rendering/compactFrame.d.ts +5 -0
  43. package/dist/types/components/rendering/yAxisTickVisibility.d.ts +11 -0
  44. package/dist/types/core/lineBoundary.d.ts +4 -0
  45. package/dist/types/core/rendering.d.ts +4 -0
  46. package/dist/types/core/renderingStrategies.d.ts +2 -0
  47. package/dist/types/core/workerSampling/outputBoundaries.d.ts +4 -0
  48. package/dist/types/data/simulatedWaveforms.d.ts +1 -1
  49. package/dist/types/data/sx2Channel.d.ts +10 -0
  50. package/dist/types/demo/DemoYAxisPaddingControls.vue.d.ts +11 -0
  51. package/dist/types/demo/types.d.ts +7 -0
  52. package/dist/types/index.d.ts +2 -1
  53. package/dist/types/types/chart.d.ts +32 -8
  54. package/dist/types/types/controls.d.ts +69 -0
  55. package/dist/types/types/index.d.ts +2 -1
  56. package/dist/types/utils/formatters.d.ts +1 -0
  57. package/package.json +7 -6
  58. package/src/App.vue +61 -14
  59. package/src/components/WaveformChart.css +10 -1
  60. package/src/components/WaveformChart.vue +6 -0
  61. package/src/components/WaveformChartView.vue +27 -26
  62. package/src/components/annotation/WaveformAnnotationLayer.vue +6 -1
  63. package/src/components/annotation/useWaveformChartAnnotations.ts +7 -18
  64. package/src/components/controls/WaveformToolbar.vue +247 -0
  65. package/src/components/controls/chartExportOptions.ts +32 -0
  66. package/src/components/controls/chartSnapshot.ts +132 -0
  67. package/src/components/controls/snapshotTitle.ts +73 -0
  68. package/src/components/controls/toolbarRegistry.ts +92 -0
  69. package/src/components/controls/useChartCommands.ts +294 -0
  70. package/src/components/controls/useChartImageExport.ts +155 -0
  71. package/src/components/controls/useControlMode.ts +30 -0
  72. package/src/components/core/latestTaskScheduler.ts +14 -2
  73. package/src/components/core/layout.ts +49 -36
  74. package/src/components/core/niceScale.ts +151 -0
  75. package/src/components/core/trackLayoutBuilder.ts +41 -17
  76. package/src/components/core/trackPagination.ts +3 -3
  77. package/src/components/core/types.ts +5 -0
  78. package/src/components/core/useWaveformChartController.ts +94 -3
  79. package/src/components/core/useWaveformChartLifecycle.ts +11 -111
  80. package/src/components/core/useWaveformData.ts +4 -0
  81. package/src/components/core/useWaveformLayout.ts +25 -5
  82. package/src/components/core/useWaveformMeasurements.ts +63 -0
  83. package/src/components/core/useWaveformRenderSampling.ts +80 -59
  84. package/src/components/core/useWaveformViewportRestore.ts +105 -0
  85. package/src/components/core/useWaveformYAxisLayout.ts +3 -2
  86. package/src/components/core/waveformChartTypes.ts +5 -0
  87. package/src/components/core/waveformSamplingSession.ts +49 -0
  88. package/src/components/core/workerSamplingClient.ts +38 -6
  89. package/src/components/core/yAxisLabelStyle.ts +10 -0
  90. package/src/components/core/yAxisLayoutMetrics.ts +11 -9
  91. package/src/components/core/yAxisPadding.ts +60 -0
  92. package/src/components/core/yAxisTitleLayout.ts +11 -12
  93. package/src/components/interaction/useExtremaTickViewport.ts +43 -0
  94. package/src/components/interaction/useWaveformViewport.ts +11 -2
  95. package/src/components/rendering/WaveformTrack.css +1 -2
  96. package/src/components/rendering/WaveformTrack.vue +5 -12
  97. package/src/components/rendering/WaveformTrackAxes.vue +66 -19
  98. package/src/components/rendering/WaveformTrackBackdrop.vue +8 -3
  99. package/src/components/rendering/compactFrame.ts +31 -0
  100. package/src/components/rendering/yAxisTickVisibility.ts +156 -0
  101. package/src/core/lineBoundary.ts +24 -0
  102. package/src/core/rendering.ts +48 -25
  103. package/src/core/renderingStrategies.ts +19 -13
  104. package/src/core/waveformPointSource.ts +23 -1
  105. package/src/core/workerSampling/outputBoundaries.ts +32 -0
  106. package/src/core/workerSampling/repository.ts +26 -18
  107. package/src/core/workerSampling/repositoryDataset.ts +33 -1
  108. package/src/core/workerSampling/workerRuntime.ts +13 -3
  109. package/src/data/simulatedWaveforms.ts +67 -2
  110. package/src/data/sx2Channel.ts +315 -0
  111. package/src/demo/DemoChartHost.vue +5 -1
  112. package/src/demo/DemoGridFrameControls.vue +2 -0
  113. package/src/demo/DemoViewControls.vue +5 -2
  114. package/src/demo/DemoYAxisPaddingControls.vue +53 -0
  115. package/src/demo/embedBridge.ts +85 -0
  116. package/src/demo/types.ts +7 -0
  117. package/src/index.ts +19 -0
  118. package/src/types/chart.ts +34 -8
  119. package/src/types/controls.ts +80 -0
  120. package/src/types/index.ts +18 -0
  121. package/src/utils/formatters.ts +2 -1
  122. package/src/views/WasmSamplingDemo.vue +8 -1
@@ -1,3 +1,5 @@
1
+ import { useWaveformMeasurements } from './useWaveformMeasurements'
2
+ import { useWaveformViewportRestore } from './useWaveformViewportRestore'
1
3
  import { zoomIdentity, type ZoomTransform } from 'd3'
2
4
  import {
3
5
  nextTick,
@@ -13,12 +15,10 @@ import type { AnnotationSeriesCandidate, useWaveformAnnotationInteraction } from
13
15
  import type { NormalizedWaveformGridOptions } from './grid'
14
16
  import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
15
17
  import type { ResolvedWaveformChartProps, WaveformChartEmit } from './waveformChartTypes'
16
- import { transformForDomain } from '../interaction/zoomConstraints'
17
- import { remotePanBoundary, restoreViewportDomain } from '../interaction/remoteViewport'
18
18
  import { isEditableTarget } from '../interaction/interactionTarget'
19
- import { seriesIdentity } from '../interaction/zoomEventPayload'
20
19
 
21
- interface LifecycleContext {
20
+ export interface LifecycleContext {
21
+ preserveFullViewport?: () => boolean
22
22
  props: ResolvedWaveformChartProps
23
23
  emit: WaveformChartEmit
24
24
  container: Ref<HTMLDivElement | undefined>
@@ -64,19 +64,10 @@ interface LifecycleContext {
64
64
  }
65
65
 
66
66
  export function useWaveformChartLifecycle(context: LifecycleContext) {
67
+ useWaveformMeasurements(context)
67
68
  const {
68
69
  props,
69
70
  emit,
70
- container,
71
- titleMeasureElement,
72
- resizeObserver,
73
- observedWidth,
74
- observedHeight,
75
- measuredTitleWidth,
76
- measuredTitleHeight,
77
- resolvedTitleText,
78
- titleAreaReserved,
79
- titleMeasureStyle,
80
71
  spacePressed,
81
72
  pointerInsideChart,
82
73
  currentPage,
@@ -85,18 +76,13 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
85
76
  gridOptions,
86
77
  chartSeries,
87
78
  chartTracks,
88
- trackLayouts,
89
79
  innerWidth,
90
80
  innerHeight,
91
- sharedZoomDomain,
92
- initialXDomain,
93
- sharedTransform,
94
81
  activeInteractionMode,
95
82
  hiddenSeriesIdSet,
96
83
  internalHiddenSeriesIds,
97
84
  independentTransforms,
98
85
  independentYDomains,
99
- resolveInitialTrackDomain,
100
86
  annotationInteraction,
101
87
  editorSeriesOptions,
102
88
  isPresentationMode,
@@ -110,6 +96,10 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
110
96
  } = context
111
97
 
112
98
  function handleInteractionKeyDown(event: KeyboardEvent) {
99
+ if (event.key === 'Escape') {
100
+ cancelViewportDrag()
101
+ cancelAnnotation()
102
+ }
113
103
  if (
114
104
  isPresentationMode.value ||
115
105
  event.code !== 'Space' ||
@@ -142,68 +132,8 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
142
132
  emit('page-change', nextPage, pageCount.value)
143
133
  }
144
134
 
145
- let pendingSharedXDomain: [number, number] | undefined
146
- let pendingIndependentXDomains: Map<string, [number, number]> | undefined
147
-
148
- function handleBeforeDataReferenceChange() {
149
- if (props.displayMode === 'independent') {
150
- pendingSharedXDomain = undefined
151
- pendingIndependentXDomains = new Map(
152
- trackLayouts.value.flatMap((track) => {
153
- const current = track.xScale.domain() as [number, number]
154
- const boundary = resolveInitialTrackDomain(track)
155
- return remotePanBoundary(props) ||
156
- current[1] - current[0] < boundary[1] - boundary[0] - 1e-12
157
- ? [[seriesIdentity(track.seriesList.map((series) => series.id)), current]]
158
- : []
159
- }),
160
- )
161
- return
162
- }
163
- pendingIndependentXDomains = undefined
164
- const current = sharedZoomDomain.value
165
- const boundary = initialXDomain.value
166
- pendingSharedXDomain =
167
- remotePanBoundary(props) || current[1] - current[0] < boundary[1] - boundary[0] - 1e-12
168
- ? [...current]
169
- : undefined
170
- }
171
-
172
- function handleDataReferenceChange() {
173
- if (props.displayMode === 'independent') {
174
- const currentTransforms = independentTransforms.value
175
- independentTransforms.value = chartTracks.value.map(
176
- (_track, index) => currentTransforms[index] ?? zoomIdentity,
177
- )
178
- }
179
- clearHover()
180
- editorSeriesOptions.value = []
181
- void nextTick(() => {
182
- if (props.displayMode === 'independent' && pendingIndependentXDomains) {
183
- const nextTransforms = chartTracks.value.map(() => zoomIdentity)
184
- trackLayouts.value.forEach((track) => {
185
- const previousDomain = pendingIndependentXDomains?.get(
186
- seriesIdentity(track.seriesList.map((series) => series.id)),
187
- )
188
- if (!previousDomain) return
189
- const boundary = resolveInitialTrackDomain(track)
190
- const domain = restoreViewportDomain(previousDomain, boundary, [track.seriesList], props)
191
- nextTransforms[track.index] = transformForDomain(domain, boundary, track.width)
192
- })
193
- independentTransforms.value = nextTransforms
194
- pendingIndependentXDomains = undefined
195
- } else if (props.displayMode !== 'independent' && pendingSharedXDomain) {
196
- const boundary = initialXDomain.value
197
- const groups = trackLayouts.value
198
- .filter((track) => track.hasVisibleSeries)
199
- .map((track) => track.seriesList)
200
- const domain = restoreViewportDomain(pendingSharedXDomain, boundary, groups, props)
201
- sharedTransform.value = transformForDomain(domain, boundary, innerWidth.value)
202
- pendingSharedXDomain = undefined
203
- }
204
- configureZoom()
205
- })
206
- }
135
+ const { handleBeforeDataReferenceChange, handleDataReferenceChange } =
136
+ useWaveformViewportRestore(context)
207
137
 
208
138
  watch(
209
139
  [
@@ -347,45 +277,15 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
347
277
  { deep: true },
348
278
  )
349
279
 
350
- function measureTitle() {
351
- if (!titleAreaReserved.value || !titleMeasureElement.value) {
352
- measuredTitleWidth.value = 0
353
- measuredTitleHeight.value = 0
354
- return
355
- }
356
- const bounds = titleMeasureElement.value.getBoundingClientRect()
357
- measuredTitleWidth.value = titleMeasureElement.value.scrollWidth || bounds.width
358
- measuredTitleHeight.value = titleMeasureElement.value.scrollHeight || bounds.height
359
- }
360
-
361
- watch(
362
- [resolvedTitleText, titleAreaReserved, titleMeasureStyle],
363
- async () => {
364
- measuredTitleWidth.value = 0
365
- measuredTitleHeight.value = 0
366
- await nextTick()
367
- measureTitle()
368
- },
369
- { immediate: true },
370
- )
371
-
372
280
  onMounted(() => {
373
281
  window.addEventListener('keydown', handleInteractionKeyDown)
374
282
  window.addEventListener('keyup', handleInteractionKeyUp)
375
- if (!container.value) return
376
- resizeObserver.value = new ResizeObserver(([entry]) => {
377
- observedWidth.value = Math.max(0, entry?.contentRect.width ?? 0)
378
- observedHeight.value = Math.max(0, entry?.contentRect.height ?? 0)
379
- void nextTick(measureTitle)
380
- })
381
- resizeObserver.value.observe(container.value)
382
283
  })
383
284
 
384
285
  onBeforeUnmount(() => {
385
286
  window.removeEventListener('keydown', handleInteractionKeyDown)
386
287
  window.removeEventListener('keyup', handleInteractionKeyUp)
387
288
  cancelPendingHover()
388
- resizeObserver.value?.disconnect()
389
289
  clearZoomBindings()
390
290
  editorSeriesOptions.value = []
391
291
  })
@@ -25,6 +25,8 @@ export interface PreparedWaveformSeries {
25
25
  points: WaveformPoint[]
26
26
  source: WaveformPointSource
27
27
  xDomain: [number, number]
28
+ rawYMaximum?: number
29
+ rawYMinimum?: number
28
30
  yDomain: [number, number]
29
31
  hasErrorPoints: boolean
30
32
  }
@@ -48,6 +50,8 @@ export function prepareWaveformSeries(data: WaveformData): PreparedWaveformSerie
48
50
  ? [sourceMetrics.yMinimum, sourceMetrics.yMaximum]
49
51
  : [],
50
52
  ),
53
+ rawYMaximum: Number.isFinite(sourceMetrics.yMaximum) ? sourceMetrics.yMaximum : undefined,
54
+ rawYMinimum: Number.isFinite(sourceMetrics.yMinimum) ? sourceMetrics.yMinimum : undefined,
51
55
  hasErrorPoints: sourceMetrics.hasErrorPoints,
52
56
  }
53
57
  })
@@ -17,9 +17,11 @@ import type { ResolvedWaveformChartProps } from './waveformChartTypes'
17
17
  import { applyXDomainStrategy } from './xDomain'
18
18
  import { alignIntegerZoomDomain, normalizeIntegerZoomDomain } from '../interaction/integerZoom'
19
19
  import { resolveViewportYDomains } from './yAxisLayoutMetrics'
20
+ import { useExtremaTickViewport } from '../interaction/useExtremaTickViewport'
20
21
  import { useWaveformYAxisLayout } from './useWaveformYAxisLayout'
21
22
  import type { useWaveformAnnotationInteraction } from '../annotation'
22
23
  interface LayoutContext {
24
+ boundaries?: ShallowRef<Record<string, [number, number]>>
23
25
  props: ResolvedWaveformChartProps
24
26
  preparedSeries: ShallowRef<PreparedWaveformSeries[]>
25
27
  currentPage: Ref<number>
@@ -147,7 +149,7 @@ export function useWaveformLayout(context: LayoutContext) {
147
149
  const hasChartArea = computed(() => innerWidth.value > 0 && innerHeight.value > 0)
148
150
  const resolvedXLabel = computed(() => props.xLabel ?? `时间(${props.timeUnit})`)
149
151
  const activeInteractionMode = computed(() => props.interactionMode)
150
- const isZoomMode = computed(() => activeInteractionMode.value !== 'annotation')
152
+ const isZoomMode = computed(() => activeInteractionMode.value === 'zoom')
151
153
  const sharedXDomain = computed(() => {
152
154
  const values: number[] = []
153
155
  const tracks = chartTracks.value.some((track) => track.visibleSeries.length)
@@ -158,7 +160,7 @@ export function useWaveformLayout(context: LayoutContext) {
158
160
  })
159
161
  return paddedDomain(values)
160
162
  })
161
- const initialXDomain = computed<[number, number]>(() => {
163
+ const originalXDomain = computed<[number, number]>(() => {
162
164
  const domain = props.initialXDomain
163
165
  if (
164
166
  domain &&
@@ -180,7 +182,7 @@ export function useWaveformLayout(context: LayoutContext) {
180
182
  props,
181
183
  )
182
184
  })
183
- const resolveInitialTrackDomain = (track: TrackLayout): [number, number] => {
185
+ const resolveOriginalTrackDomain = (track: TrackLayout): [number, number] => {
184
186
  const configuredDomain =
185
187
  props.initialXDomains?.[track.series?.trackId ?? track.series?.id ?? track.id] ??
186
188
  (track.series ? props.initialXDomains?.[track.series.id] : undefined) ??
@@ -210,6 +212,13 @@ export function useWaveformLayout(context: LayoutContext) {
210
212
  props,
211
213
  )
212
214
  }
215
+ const initialXDomain = computed(
216
+ () =>
217
+ (props.displayMode !== 'independent' ? context.boundaries?.value.shared : undefined) ??
218
+ originalXDomain.value,
219
+ )
220
+ const resolveInitialTrackDomain = (track: TrackLayout): [number, number] =>
221
+ context.boundaries?.value[track.id] ?? resolveOriginalTrackDomain(track)
213
222
  const sharedZoomDomain = computed(() =>
214
223
  normalizeIntegerZoomDomain(
215
224
  sharedTransform.value
@@ -240,6 +249,7 @@ export function useWaveformLayout(context: LayoutContext) {
240
249
  useNonEmptyBottomTracks: props.layoutPreset === 'edge-compact',
241
250
  compactYAxisLayout: props.layoutPreset === 'edge-compact',
242
251
  showAxisUnits:
252
+ props.unitDisplayMode !== 'channel-label' &&
243
253
  props.unitDisplayMode !== 'channel-label-or-legend' &&
244
254
  (props.layoutPreset !== 'edge-compact' || props.unitDisplayMode === 'axis'),
245
255
  displayMode: props.displayMode,
@@ -248,6 +258,7 @@ export function useWaveformLayout(context: LayoutContext) {
248
258
  sharedZoomDomain: sharedZoomDomain.value,
249
259
  initialXDomain: props.initialXDomain ? initialXDomain.value : undefined,
250
260
  initialXDomains: props.initialXDomains,
261
+ effectiveXDomains: context.boundaries?.value,
251
262
  xDomainStrategy: props.xDomainStrategy,
252
263
  integerZoom: props.integerZoom,
253
264
  fixedYDomain: props.yDomain,
@@ -257,7 +268,7 @@ export function useWaveformLayout(context: LayoutContext) {
257
268
  xAxisLabelFormatter: props.axes?.x?.labelFormatter,
258
269
  yAxisSplitNumber: props.axes?.y?.splitNumber,
259
270
  yAxisNice: props.axes?.y?.nice,
260
- yAxisUpperPaddingRatio: props.axes?.y?.upperPaddingRatio,
271
+ yAxisPadding: props.axes?.y,
261
272
  rendering: renderingOptions.value,
262
273
  linePointOverrides: linePointOverrides?.value,
263
274
  hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
@@ -266,9 +277,16 @@ export function useWaveformLayout(context: LayoutContext) {
266
277
  yAxisSlots.value.slots.find((slot) => slot.side === 'left' && slot.sideIndex === 0)
267
278
  ?.labelOffset ?? yAxisMetrics.value.labelCenterX,
268
279
  yAxisSlots: yAxisSlots.value.slots,
269
- showCompactEmptyTracks: props.displayMode === 'compact' && hasWaveformData.value,
280
+ showEmptyTracks: hasWaveformData.value,
270
281
  }),
271
282
  )
283
+ useExtremaTickViewport(
284
+ () => props.axes?.y?.extremaTicksVisible !== false,
285
+ innerWidth,
286
+ trackLayouts,
287
+ sharedTransform,
288
+ independentTransforms,
289
+ )
272
290
  const annotationLayoutsForTrack = (track: TrackLayout): AnnotationTrackLayout[] =>
273
291
  track.seriesList.map((series) => ({
274
292
  ...track,
@@ -329,6 +347,8 @@ export function useWaveformLayout(context: LayoutContext) {
329
347
  activeInteractionMode,
330
348
  isZoomMode,
331
349
  initialXDomain,
350
+ originalXDomain,
351
+ resolveOriginalTrackDomain,
332
352
  sharedZoomDomain,
333
353
  resolveInitialTrackDomain,
334
354
  gridCells,
@@ -0,0 +1,63 @@
1
+ import { nextTick, watch, onMounted, onBeforeUnmount } from 'vue'
2
+ import type { LifecycleContext } from './useWaveformChartLifecycle'
3
+
4
+ export function useWaveformMeasurements(
5
+ context: Pick<
6
+ LifecycleContext,
7
+ | 'container'
8
+ | 'titleMeasureElement'
9
+ | 'resizeObserver'
10
+ | 'observedWidth'
11
+ | 'observedHeight'
12
+ | 'measuredTitleWidth'
13
+ | 'measuredTitleHeight'
14
+ | 'resolvedTitleText'
15
+ | 'titleAreaReserved'
16
+ | 'titleMeasureStyle'
17
+ >,
18
+ ) {
19
+ const {
20
+ container,
21
+ titleMeasureElement,
22
+ resizeObserver,
23
+ observedWidth,
24
+ observedHeight,
25
+ measuredTitleWidth,
26
+ measuredTitleHeight,
27
+ resolvedTitleText,
28
+ titleAreaReserved,
29
+ titleMeasureStyle,
30
+ } = context
31
+ function measureTitle() {
32
+ if (!titleAreaReserved.value || !titleMeasureElement.value) {
33
+ measuredTitleWidth.value = 0
34
+ measuredTitleHeight.value = 0
35
+ return
36
+ }
37
+ const bounds = titleMeasureElement.value.getBoundingClientRect()
38
+ measuredTitleWidth.value = titleMeasureElement.value.scrollWidth || bounds.width
39
+ measuredTitleHeight.value = titleMeasureElement.value.scrollHeight || bounds.height
40
+ }
41
+
42
+ watch(
43
+ [resolvedTitleText, titleAreaReserved, titleMeasureStyle],
44
+ async () => {
45
+ measuredTitleWidth.value = 0
46
+ measuredTitleHeight.value = 0
47
+ await nextTick()
48
+ measureTitle()
49
+ },
50
+ { immediate: true },
51
+ )
52
+
53
+ onMounted(() => {
54
+ if (!container.value) return
55
+ resizeObserver.value = new ResizeObserver(([entry]) => {
56
+ observedWidth.value = Math.max(0, entry?.contentRect.width ?? 0)
57
+ observedHeight.value = Math.max(0, entry?.contentRect.height ?? 0)
58
+ void nextTick(measureTitle)
59
+ })
60
+ resizeObserver.value.observe(container.value)
61
+ })
62
+ onBeforeUnmount(() => resizeObserver.value?.disconnect())
63
+ }
@@ -1,3 +1,5 @@
1
+ import { createWaveformSamplingSession } from './waveformSamplingSession'
2
+ import { lineBoundaryIndexes, withLineBoundaries } from '../../core/lineBoundary'
1
3
  import { computed, onScopeDispose, shallowRef, watch, type ComputedRef, type ShallowRef } from 'vue'
2
4
 
3
5
  import {
@@ -11,12 +13,8 @@ import { pointSourceFromPoints, type WaveformPointSource } from '../../core/wave
11
13
  import type { PreparedWaveformSeries } from './useWaveformData'
12
14
  import type { DisplaySeries, TrackLayout } from './types'
13
15
  import type { ResolvedWaveformChartProps, WaveformChartEmit } from './waveformChartTypes'
14
- import { createLatestTaskScheduler, resolveAutoSelectedMode } from './latestTaskScheduler'
15
- import {
16
- createWorkerSamplingClient,
17
- isDatasetResponse,
18
- type WorkerSamplingClient,
19
- } from './workerSamplingClient'
16
+ import { resolveAutoSelectedMode } from './latestTaskScheduler'
17
+ import { isDatasetResponse, type WorkerSamplingClient } from './workerSamplingClient'
20
18
 
21
19
  interface SamplingTarget {
22
20
  series: DisplaySeries
@@ -62,7 +60,8 @@ function rawDiagnostics(target: SamplingTarget, requestId: number): WorkerSampli
62
60
  strategy: sampling.strategy === 'auto' ? 'peak' : sampling.strategy,
63
61
  sourcePointCount: target.source.length,
64
62
  visiblePointCount,
65
- renderedPointCount: visiblePointCount,
63
+ renderedPointCount:
64
+ visiblePointCount + lineBoundaryIndexes(target.visibleRange, target.source.length).length,
66
65
  durationMs: 0,
67
66
  cacheHit: false,
68
67
  requestId,
@@ -75,7 +74,7 @@ function rawDiagnostics(target: SamplingTarget, requestId: number): WorkerSampli
75
74
  }
76
75
  }
77
76
 
78
- function fallbackPoints(target: SamplingTarget) {
77
+ function fallbackInteriorPoints(target: SamplingTarget) {
79
78
  const { source } = target
80
79
  const { start, end } = target.visibleRange
81
80
  const count = end - start
@@ -104,18 +103,17 @@ function errorPayload(
104
103
  }
105
104
 
106
105
  export function useWaveformRenderSampling(context: SamplingContext) {
106
+ const settledSignature = shallowRef<string>()
107
107
  const dataEpoch = shallowRef(0)
108
- let client: WorkerSamplingClient | undefined
109
- let requestId = 0
110
- const revisions = new Map<string, number>()
111
- const backendBySeries = new Map<string, WorkerSamplingDiagnostics['backend']>()
112
- const selectedModeBySeries = new Map<string, 'raw' | 'sampled'>()
113
- const emittedErrors = new Set<string>()
114
- const pendingDiagnostics = new Map<string, WorkerSamplingDiagnostics>()
115
- const samplingScheduler = createLatestTaskScheduler()
116
- let diagnosticsTimer: ReturnType<typeof setTimeout> | undefined
117
- let autoModeSettleTimer: ReturnType<typeof setTimeout> | undefined
118
- let hasInitialSamplingRun = false
108
+ const session = createWaveformSamplingSession()
109
+ const {
110
+ revisions,
111
+ backendBySeries,
112
+ selectedModeBySeries,
113
+ emittedErrors,
114
+ pendingDiagnostics,
115
+ scheduler: samplingScheduler,
116
+ } = session
119
117
 
120
118
  const targets = computed(() => {
121
119
  const sampling = context.renderingOptions.value.sampling
@@ -169,7 +167,7 @@ export function useWaveformRenderSampling(context: SamplingContext) {
169
167
  )
170
168
 
171
169
  const flushDiagnostics = () => {
172
- diagnosticsTimer = undefined
170
+ session.diagnosticsTimer = undefined
173
171
  pendingDiagnostics.forEach((diagnostic) => {
174
172
  const previousBackend = backendBySeries.get(diagnostic.seriesId)
175
173
  if (previousBackend && previousBackend !== diagnostic.backend) {
@@ -193,7 +191,7 @@ export function useWaveformRenderSampling(context: SamplingContext) {
193
191
  maxPendingRequestCount: samplingScheduler.metrics.maxPending,
194
192
  }),
195
193
  )
196
- if (!diagnosticsTimer) diagnosticsTimer = setTimeout(flushDiagnostics, 100)
194
+ if (!session.diagnosticsTimer) session.diagnosticsTimer = setTimeout(flushDiagnostics, 100)
197
195
  }
198
196
 
199
197
  const emitErrorOnce = (payload: WaveformSamplingError) => {
@@ -213,7 +211,7 @@ export function useWaveformRenderSampling(context: SamplingContext) {
213
211
  if (revisions.has(target.request.datasetId)) return
214
212
  const response = await workerClient.send({
215
213
  type: 'register-dataset',
216
- requestId: ++requestId,
214
+ requestId: ++session.requestId,
217
215
  datasetId: target.request.datasetId,
218
216
  revision: 0,
219
217
  dataset: target.source.toWorkerDataset(),
@@ -249,15 +247,22 @@ export function useWaveformRenderSampling(context: SamplingContext) {
249
247
  previousSelectedMode,
250
248
  ) === 'raw'
251
249
  if (target.request.mode === 'raw' || autoRaw) {
252
- nextOverrides[target.series.id] = target.source.pointsInRange(
253
- target.visibleRange.start,
254
- target.visibleRange.end,
250
+ nextOverrides[target.series.id] = withLineBoundaries(
251
+ target.source.pointsInRange(target.visibleRange.start, target.visibleRange.end),
252
+ target.visibleRange,
253
+ target.source.length,
254
+ (index) => target.source.pointAt(index)!,
255
255
  )
256
256
  selectedModeBySeries.set(target.series.id, 'raw')
257
257
  diagnostics.push(rawDiagnostics(target, token))
258
258
  } else {
259
259
  if (!nextOverrides[target.series.id]?.length) {
260
- nextOverrides[target.series.id] = fallbackPoints(target)
260
+ nextOverrides[target.series.id] = withLineBoundaries(
261
+ fallbackInteriorPoints(target),
262
+ target.visibleRange,
263
+ target.source.length,
264
+ (index) => target.source.pointAt(index)!,
265
+ )
261
266
  }
262
267
  sampledTargets.push({
263
268
  ...target,
@@ -273,18 +278,18 @@ export function useWaveformRenderSampling(context: SamplingContext) {
273
278
  }
274
279
 
275
280
  context.linePointOverrides.value = nextOverrides
276
- client ??= createWorkerSamplingClient()
277
- await registerTargets(client, sampledTargets, token)
278
- if (!samplingScheduler.isCurrent(token)) return
279
- const batch = sampledTargets.map((target) => ({
280
- ...target.request,
281
- revision: revisions.get(target.request.datasetId) ?? 1,
282
- }))
281
+ const client = session.getClient()
283
282
  let response: SampleViewportResponse
284
283
  try {
284
+ await registerTargets(client, sampledTargets, token)
285
+ if (!samplingScheduler.isCurrent(token)) return
286
+ const batch = sampledTargets.map((target) => ({
287
+ ...target.request,
288
+ revision: revisions.get(target.request.datasetId) ?? 1,
289
+ }))
285
290
  const result = await client.send({
286
291
  type: 'sample-viewport',
287
- requestId: ++requestId,
292
+ requestId: ++session.requestId,
288
293
  series: batch,
289
294
  })
290
295
  if (result.type !== 'sample-viewport-response') return
@@ -295,7 +300,10 @@ export function useWaveformRenderSampling(context: SamplingContext) {
295
300
  errorPayload(
296
301
  error instanceof Error ? error.message : String(error),
297
302
  context.renderingOptions.value.sampling.mode,
298
- 'javascript',
303
+ context.renderingOptions.value.sampling.mode === 'wasm' &&
304
+ context.renderingOptions.value.sampling.wasmFailureFallback === 'error'
305
+ ? 'none'
306
+ : 'javascript',
299
307
  sampledTargets.map((target) => target.series.id),
300
308
  ),
301
309
  )
@@ -322,12 +330,18 @@ export function useWaveformRenderSampling(context: SamplingContext) {
322
330
  )
323
331
  }
324
332
  })
325
- if (response.workerError) {
333
+ if (
334
+ response.workerError &&
335
+ response.results.every((result) => result.status !== 'wasm-unavailable')
336
+ ) {
326
337
  emitErrorOnce(
327
338
  errorPayload(
328
339
  response.workerError,
329
340
  context.renderingOptions.value.sampling.mode,
330
- 'javascript',
341
+ context.renderingOptions.value.sampling.mode === 'wasm' &&
342
+ context.renderingOptions.value.sampling.wasmFailureFallback === 'error'
343
+ ? 'none'
344
+ : 'javascript',
331
345
  sampledTargets.map((target) => target.series.id),
332
346
  ),
333
347
  )
@@ -340,7 +354,10 @@ export function useWaveformRenderSampling(context: SamplingContext) {
340
354
  errorPayload(
341
355
  client.workerFailureReason,
342
356
  context.renderingOptions.value.sampling.mode,
343
- 'javascript',
357
+ context.renderingOptions.value.sampling.mode === 'wasm' &&
358
+ context.renderingOptions.value.sampling.wasmFailureFallback === 'error'
359
+ ? 'none'
360
+ : 'javascript',
344
361
  sampledTargets.map((target) => target.series.id),
345
362
  ),
346
363
  )
@@ -349,21 +366,33 @@ export function useWaveformRenderSampling(context: SamplingContext) {
349
366
  emitDiagnostics(diagnostics)
350
367
  }
351
368
 
369
+ const scheduleSampling = (hysteresis: boolean) => {
370
+ settledSignature.value = undefined
371
+ const signature = targetSignature.value
372
+ samplingScheduler.schedule(async (token) => {
373
+ try {
374
+ await runSampling(token, hysteresis)
375
+ } finally {
376
+ if (samplingScheduler.isCurrent(token)) settledSignature.value = signature
377
+ }
378
+ })
379
+ }
380
+
352
381
  watch(
353
382
  targetSignature,
354
383
  () => {
355
- if (autoModeSettleTimer) clearTimeout(autoModeSettleTimer)
356
- const useAutoHysteresis = hasInitialSamplingRun
357
- hasInitialSamplingRun = true
358
- samplingScheduler.schedule((token) => runSampling(token, useAutoHysteresis))
384
+ if (session.autoModeSettleTimer) clearTimeout(session.autoModeSettleTimer)
385
+ const useAutoHysteresis = session.hasInitialSamplingRun
386
+ session.hasInitialSamplingRun = true
387
+ scheduleSampling(useAutoHysteresis)
359
388
  if (
360
389
  useAutoHysteresis &&
361
390
  context.renderingOptions.value.sampling.mode === 'auto' &&
362
391
  context.renderingOptions.value.sampling.autoHysteresis > 0
363
392
  ) {
364
- autoModeSettleTimer = setTimeout(() => {
365
- autoModeSettleTimer = undefined
366
- samplingScheduler.schedule((token) => runSampling(token, false))
393
+ session.autoModeSettleTimer = setTimeout(() => {
394
+ session.autoModeSettleTimer = undefined
395
+ scheduleSampling(false)
367
396
  }, AUTO_MODE_SETTLE_DELAY_MS)
368
397
  }
369
398
  },
@@ -372,24 +401,16 @@ export function useWaveformRenderSampling(context: SamplingContext) {
372
401
  watch(
373
402
  context.preparedSeries,
374
403
  () => {
375
- samplingScheduler.cancelPending()
404
+ session.invalidate()
376
405
  dataEpoch.value += 1
377
406
  context.linePointOverrides.value = {}
378
- revisions.clear()
379
- backendBySeries.clear()
380
- selectedModeBySeries.clear()
381
- emittedErrors.clear()
382
- client?.dispose()
383
- client = undefined
384
407
  },
385
408
  { flush: 'sync' },
386
409
  )
387
- onScopeDispose(() => {
388
- if (diagnosticsTimer) clearTimeout(diagnosticsTimer)
389
- if (autoModeSettleTimer) clearTimeout(autoModeSettleTimer)
390
- samplingScheduler.dispose()
391
- client?.dispose()
392
- })
410
+ onScopeDispose(() => session.dispose())
393
411
 
394
- return { linePointOverrides: context.linePointOverrides }
412
+ return {
413
+ linePointOverrides: context.linePointOverrides,
414
+ ready: () => settledSignature.value === targetSignature.value && !session.autoModeSettleTimer,
415
+ }
395
416
  }