waveform-analysis 0.1.70 → 0.1.72

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 (120) hide show
  1. package/README.md +244 -45
  2. package/dist/assets/workerRuntime-5p-EI-GL.js +1 -0
  3. package/dist/index.cjs +2 -1
  4. package/dist/index.js +2924 -1915
  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/controls/WaveformToolbar.vue.d.ts +7 -0
  9. package/dist/types/components/controls/chartExportOptions.d.ts +5 -0
  10. package/dist/types/components/controls/chartSnapshot.d.ts +2 -0
  11. package/dist/types/components/controls/snapshotTitle.d.ts +2 -0
  12. package/dist/types/components/controls/toolbarRegistry.d.ts +21 -0
  13. package/dist/types/components/controls/useChartCommands.d.ts +42 -0
  14. package/dist/types/components/controls/useChartImageExport.d.ts +17 -0
  15. package/dist/types/components/controls/useControlMode.d.ts +8 -0
  16. package/dist/types/components/core/latestTaskScheduler.d.ts +1 -1
  17. package/dist/types/components/core/layout.d.ts +5 -3
  18. package/dist/types/components/core/niceScale.d.ts +34 -0
  19. package/dist/types/components/core/pagination.d.ts +2 -2
  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 +3 -0
  23. package/dist/types/components/core/useWaveformChartController.d.ts +24 -5
  24. package/dist/types/components/core/useWaveformChartLifecycle.d.ts +2 -2
  25. package/dist/types/components/core/useWaveformData.d.ts +1 -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/useWaveformViewport.d.ts +1 -0
  37. package/dist/types/components/rendering/WaveformLegend.vue.d.ts +2 -2
  38. package/dist/types/components/rendering/WaveformTrack.vue.d.ts +2 -3
  39. package/dist/types/components/rendering/WaveformTrackAxes.vue.d.ts +0 -1
  40. package/dist/types/components/rendering/WaveformTrackBackdrop.vue.d.ts +1 -0
  41. package/dist/types/components/rendering/compactFrame.d.ts +5 -0
  42. package/dist/types/components/rendering/yAxisTickVisibility.d.ts +11 -0
  43. package/dist/types/core/lineBoundary.d.ts +4 -0
  44. package/dist/types/core/rendering.d.ts +4 -0
  45. package/dist/types/core/renderingStrategies.d.ts +2 -0
  46. package/dist/types/core/workerSampling/outputBoundaries.d.ts +4 -0
  47. package/dist/types/data/simulatedWaveforms.d.ts +1 -1
  48. package/dist/types/demo/DemoYAxisPaddingControls.vue.d.ts +11 -0
  49. package/dist/types/demo/types.d.ts +6 -0
  50. package/dist/types/index.d.ts +2 -1
  51. package/dist/types/types/chart.d.ts +30 -8
  52. package/dist/types/types/controls.d.ts +69 -0
  53. package/dist/types/types/index.d.ts +2 -1
  54. package/dist/types/utils/formatters.d.ts +1 -0
  55. package/package.json +7 -6
  56. package/src/App.vue +58 -14
  57. package/src/components/WaveformChart.css +10 -1
  58. package/src/components/WaveformChart.vue +6 -0
  59. package/src/components/WaveformChartView.vue +27 -23
  60. package/src/components/annotation/WaveformAnnotationLayer.vue +6 -1
  61. package/src/components/annotation/useWaveformChartAnnotations.ts +6 -1
  62. package/src/components/controls/WaveformToolbar.vue +248 -0
  63. package/src/components/controls/chartExportOptions.ts +32 -0
  64. package/src/components/controls/chartSnapshot.ts +132 -0
  65. package/src/components/controls/snapshotTitle.ts +73 -0
  66. package/src/components/controls/toolbarRegistry.ts +92 -0
  67. package/src/components/controls/useChartCommands.ts +294 -0
  68. package/src/components/controls/useChartImageExport.ts +155 -0
  69. package/src/components/controls/useControlMode.ts +30 -0
  70. package/src/components/core/latestTaskScheduler.ts +14 -2
  71. package/src/components/core/layout.ts +35 -34
  72. package/src/components/core/niceScale.ts +151 -0
  73. package/src/components/core/pagination.ts +3 -16
  74. package/src/components/core/trackLayoutBuilder.ts +34 -17
  75. package/src/components/core/trackPagination.ts +3 -3
  76. package/src/components/core/types.ts +3 -0
  77. package/src/components/core/useWaveformChartController.ts +95 -10
  78. package/src/components/core/useWaveformChartLifecycle.ts +11 -111
  79. package/src/components/core/useWaveformData.ts +2 -0
  80. package/src/components/core/useWaveformLayout.ts +17 -5
  81. package/src/components/core/useWaveformMeasurements.ts +63 -0
  82. package/src/components/core/useWaveformRenderSampling.ts +80 -59
  83. package/src/components/core/useWaveformViewportRestore.ts +105 -0
  84. package/src/components/core/useWaveformYAxisLayout.ts +3 -2
  85. package/src/components/core/waveformChartTypes.ts +5 -0
  86. package/src/components/core/waveformSamplingSession.ts +49 -0
  87. package/src/components/core/workerSamplingClient.ts +38 -6
  88. package/src/components/core/yAxisLabelStyle.ts +10 -0
  89. package/src/components/core/yAxisLayoutMetrics.ts +10 -9
  90. package/src/components/core/yAxisPadding.ts +45 -0
  91. package/src/components/core/yAxisTitleLayout.ts +11 -12
  92. package/src/components/interaction/useWaveformViewport.ts +11 -2
  93. package/src/components/rendering/WaveformTrack.css +1 -2
  94. package/src/components/rendering/WaveformTrack.vue +4 -12
  95. package/src/components/rendering/WaveformTrackAxes.vue +48 -19
  96. package/src/components/rendering/WaveformTrackBackdrop.vue +8 -3
  97. package/src/components/rendering/compactFrame.ts +31 -0
  98. package/src/components/rendering/yAxisTickVisibility.ts +95 -0
  99. package/src/core/lineBoundary.ts +24 -0
  100. package/src/core/rendering.ts +48 -25
  101. package/src/core/renderingStrategies.ts +19 -13
  102. package/src/core/waveformPointSource.ts +23 -1
  103. package/src/core/workerSampling/outputBoundaries.ts +32 -0
  104. package/src/core/workerSampling/repository.ts +26 -18
  105. package/src/core/workerSampling/repositoryDataset.ts +33 -1
  106. package/src/core/workerSampling/workerRuntime.ts +13 -3
  107. package/src/data/simulatedWaveforms.ts +57 -2
  108. package/src/demo/DemoChartHost.vue +5 -1
  109. package/src/demo/DemoGridFrameControls.vue +2 -0
  110. package/src/demo/DemoViewControls.vue +5 -2
  111. package/src/demo/DemoYAxisPaddingControls.vue +46 -0
  112. package/src/demo/embedBridge.ts +85 -0
  113. package/src/demo/types.ts +6 -0
  114. package/src/index.ts +18 -0
  115. package/src/types/chart.ts +32 -8
  116. package/src/types/controls.ts +80 -0
  117. package/src/types/index.ts +18 -0
  118. package/src/utils/formatters.ts +2 -1
  119. package/src/views/WasmSamplingDemo.vue +8 -1
  120. package/dist/assets/workerRuntime-BcYng9TR.js +0 -1
@@ -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
  }
@@ -0,0 +1,105 @@
1
+ import { remotePanBoundary, restoreViewportDomain } from '../interaction/remoteViewport'
2
+ import { nextTick } from 'vue'
3
+ import { zoomIdentity } from 'd3'
4
+ import { transformForDomain } from '../interaction/zoomConstraints'
5
+ import { seriesIdentity } from '../interaction/zoomEventPayload'
6
+ import type { LifecycleContext } from './useWaveformChartLifecycle'
7
+
8
+ export function useWaveformViewportRestore(
9
+ context: Pick<
10
+ LifecycleContext,
11
+ | 'preserveFullViewport'
12
+ | 'props'
13
+ | 'trackLayouts'
14
+ | 'resolveInitialTrackDomain'
15
+ | 'sharedZoomDomain'
16
+ | 'initialXDomain'
17
+ | 'independentTransforms'
18
+ | 'chartTracks'
19
+ | 'clearHover'
20
+ | 'editorSeriesOptions'
21
+ | 'sharedTransform'
22
+ | 'innerWidth'
23
+ | 'configureZoom'
24
+ >,
25
+ ) {
26
+ const {
27
+ props,
28
+ trackLayouts,
29
+ resolveInitialTrackDomain,
30
+ sharedZoomDomain,
31
+ initialXDomain,
32
+ independentTransforms,
33
+ chartTracks,
34
+ clearHover,
35
+ editorSeriesOptions,
36
+ sharedTransform,
37
+ innerWidth,
38
+ configureZoom,
39
+ } = context
40
+ let pendingSharedXDomain: [number, number] | undefined
41
+ let pendingIndependentXDomains: Map<string, [number, number]> | undefined
42
+
43
+ function handleBeforeDataReferenceChange() {
44
+ if (props.displayMode === 'independent') {
45
+ pendingSharedXDomain = undefined
46
+ pendingIndependentXDomains = new Map(
47
+ trackLayouts.value.flatMap((track) => {
48
+ const current = track.xScale.domain() as [number, number]
49
+ const boundary = resolveInitialTrackDomain(track)
50
+ return context.preserveFullViewport?.() || remotePanBoundary(props) ||
51
+ current[1] - current[0] < boundary[1] - boundary[0] - 1e-12
52
+ ? [[seriesIdentity(track.seriesList.map((series) => series.id)), current]]
53
+ : []
54
+ }),
55
+ )
56
+ return
57
+ }
58
+ pendingIndependentXDomains = undefined
59
+ const current = sharedZoomDomain.value
60
+ const boundary = initialXDomain.value
61
+ pendingSharedXDomain =
62
+ context.preserveFullViewport?.() || remotePanBoundary(props) ||
63
+ current[1] - current[0] < boundary[1] - boundary[0] - 1e-12
64
+ ? [...current]
65
+ : undefined
66
+ }
67
+
68
+ function handleDataReferenceChange() {
69
+ if (props.displayMode === 'independent') {
70
+ const currentTransforms = independentTransforms.value
71
+ independentTransforms.value = chartTracks.value.map(
72
+ (_track, index) => currentTransforms[index] ?? zoomIdentity,
73
+ )
74
+ }
75
+ clearHover()
76
+ editorSeriesOptions.value = []
77
+ void nextTick(() => {
78
+ if (props.displayMode === 'independent' && pendingIndependentXDomains) {
79
+ const nextTransforms = chartTracks.value.map(() => zoomIdentity)
80
+ trackLayouts.value.forEach((track) => {
81
+ const previousDomain = pendingIndependentXDomains?.get(
82
+ seriesIdentity(track.seriesList.map((series) => series.id)),
83
+ )
84
+ if (!previousDomain) return
85
+ const boundary = resolveInitialTrackDomain(track)
86
+ const domain = restoreViewportDomain(previousDomain, boundary, [track.seriesList], props)
87
+ nextTransforms[track.index] = transformForDomain(domain, boundary, track.width)
88
+ })
89
+ independentTransforms.value = nextTransforms
90
+ pendingIndependentXDomains = undefined
91
+ } else if (props.displayMode !== 'independent' && pendingSharedXDomain) {
92
+ const boundary = initialXDomain.value
93
+ const groups = trackLayouts.value
94
+ .filter((track) => track.hasVisibleSeries)
95
+ .map((track) => track.seriesList)
96
+ const domain = restoreViewportDomain(pendingSharedXDomain, boundary, groups, props)
97
+ sharedTransform.value = transformForDomain(domain, boundary, innerWidth.value)
98
+ pendingSharedXDomain = undefined
99
+ }
100
+ configureZoom()
101
+ })
102
+ }
103
+
104
+ return { handleBeforeDataReferenceChange, handleDataReferenceChange }
105
+ }
@@ -32,6 +32,7 @@ export function useWaveformYAxisLayout(context: YAxisLayoutContext) {
32
32
  const isEdgeCompact = computed(() => props.layoutPreset === 'edge-compact')
33
33
  const showUnits = computed(
34
34
  () =>
35
+ props.unitDisplayMode !== 'channel-label' &&
35
36
  props.unitDisplayMode !== 'channel-label-or-legend' &&
36
37
  (!isEdgeCompact.value || props.unitDisplayMode === 'axis'),
37
38
  )
@@ -45,7 +46,7 @@ export function useWaveformYAxisLayout(context: YAxisLayoutContext) {
45
46
  props.axes?.y?.nice !== false,
46
47
  yAxisTickCount.value,
47
48
  props.displayMode === 'compact',
48
- props.axes?.y?.upperPaddingRatio,
49
+ props.axes?.y,
49
50
  showUnits.value,
50
51
  isEdgeCompact.value ? measureYAxisTextWidth : undefined,
51
52
  ),
@@ -80,7 +81,7 @@ export function useWaveformYAxisLayout(context: YAxisLayoutContext) {
80
81
  props.axes?.y?.nice !== false,
81
82
  props.displayMode === 'compact',
82
83
  viewportYDomains.value,
83
- props.axes?.y?.upperPaddingRatio,
84
+ props.axes?.y,
84
85
  showUnits.value,
85
86
  isEdgeCompact.value ? measureYAxisTextWidth : undefined,
86
87
  ),
@@ -1,3 +1,4 @@
1
+ import type { WaveformToolbarOptions, WaveformControlState } from '../../types/controls'
1
2
  import type {
2
3
  WaveformAnnotation,
3
4
  WaveformAxesOptions,
@@ -26,6 +27,7 @@ import type {
26
27
  import type { WaveformGridOptions } from './grid'
27
28
 
28
29
  export interface WaveformChartProps {
30
+ toolbar?: boolean | WaveformToolbarOptions
29
31
  data: WaveformData
30
32
  displayMode?: WaveformDisplayMode
31
33
  overlayMode?: WaveformOverlayMode
@@ -104,6 +106,9 @@ export type ResolvedWaveformChartProps = Readonly<
104
106
  >
105
107
 
106
108
  export interface WaveformChartEmit {
109
+ (event: 'update:interactionMode', mode: WaveformInteractionMode): void
110
+ (event: 'interaction-mode-change', mode: WaveformInteractionMode): void
111
+ (event: 'control-state-change', state: WaveformControlState): void
107
112
  (event: 'point-hover', point: WaveformPoint | null): void
108
113
  (event: 'zoom-intent', payload: WaveformZoomIntentPayload): void
109
114
  (event: 'zoom-change', domain: [number, number]): void
@@ -0,0 +1,49 @@
1
+ import { createLatestTaskScheduler } from './latestTaskScheduler'
2
+ import { createWorkerSamplingClient, type WorkerSamplingClient } from './workerSamplingClient'
3
+ import type { WorkerSamplingDiagnostics } from '../../core/workerSampling'
4
+
5
+ /** Owns resources for one immutable data reference. Invalidation cancels old work before reuse. */
6
+ export function createWaveformSamplingSession() {
7
+ const scheduler = createLatestTaskScheduler()
8
+ const revisions = new Map<string, number>()
9
+ const backendBySeries = new Map<string, WorkerSamplingDiagnostics['backend']>()
10
+ const selectedModeBySeries = new Map<string, 'raw' | 'sampled'>()
11
+ const emittedErrors = new Set<string>()
12
+ const pendingDiagnostics = new Map<string, WorkerSamplingDiagnostics>()
13
+ const session = {
14
+ scheduler,
15
+ revisions,
16
+ backendBySeries,
17
+ selectedModeBySeries,
18
+ emittedErrors,
19
+ pendingDiagnostics,
20
+ client: undefined as WorkerSamplingClient | undefined,
21
+ requestId: 0,
22
+ diagnosticsTimer: undefined as ReturnType<typeof setTimeout> | undefined,
23
+ autoModeSettleTimer: undefined as ReturnType<typeof setTimeout> | undefined,
24
+ hasInitialSamplingRun: false,
25
+ getClient() {
26
+ return (session.client ??= createWorkerSamplingClient())
27
+ },
28
+ invalidate() {
29
+ scheduler.cancelPending()
30
+ clearTimeout(session.diagnosticsTimer)
31
+ clearTimeout(session.autoModeSettleTimer)
32
+ session.diagnosticsTimer = undefined
33
+ session.autoModeSettleTimer = undefined
34
+ session.hasInitialSamplingRun = false
35
+ pendingDiagnostics.clear()
36
+ revisions.clear()
37
+ backendBySeries.clear()
38
+ selectedModeBySeries.clear()
39
+ emittedErrors.clear()
40
+ session.client?.dispose()
41
+ session.client = undefined
42
+ },
43
+ dispose() {
44
+ session.invalidate()
45
+ scheduler.dispose()
46
+ },
47
+ }
48
+ return session
49
+ }
@@ -30,6 +30,7 @@ function workerFailureMessage(event?: ErrorEvent) {
30
30
  }
31
31
 
32
32
  export function createWorkerSamplingClient(): WorkerSamplingClient {
33
+ let disposed = false
33
34
  let worker: Worker | undefined
34
35
  let fallback: WorkerSamplingRepository | undefined
35
36
  let workerFailureReason: string | undefined
@@ -37,7 +38,8 @@ export function createWorkerSamplingClient(): WorkerSamplingClient {
37
38
  const datasets = new Map<string, StoredDataset>()
38
39
 
39
40
  const fallbackRepository = () => {
40
- fallback ??= new WorkerSamplingRepository()
41
+ if (fallback) return fallback
42
+ fallback = new WorkerSamplingRepository()
41
43
  for (const [datasetId, dataset] of datasets) {
42
44
  fallback.handle({
43
45
  type: 'register-dataset',
@@ -83,12 +85,26 @@ export function createWorkerSamplingClient(): WorkerSamplingClient {
83
85
  const resolveFallbackRequests = () => {
84
86
  const outstanding = [...pending.values()]
85
87
  pending.clear()
86
- const repository = fallbackRepository()
87
- outstanding.forEach(({ request, resolve }) => resolve(repository.handle(request)))
88
+ let repository: WorkerSamplingRepository
89
+ try {
90
+ repository = fallbackRepository()
91
+ } catch (error) {
92
+ outstanding.forEach(({ reject }) =>
93
+ reject(error instanceof Error ? error : new Error(String(error))),
94
+ )
95
+ return
96
+ }
97
+ outstanding.forEach(({ request, resolve, reject }) => {
98
+ try {
99
+ resolve(repository.handle(request))
100
+ } catch (error) {
101
+ reject(error instanceof Error ? error : new Error(String(error)))
102
+ }
103
+ })
88
104
  }
89
105
 
90
106
  const fallBackToJavascript = (event?: ErrorEvent) => {
91
- if (fallback) return
107
+ if (disposed || fallback) return
92
108
  workerFailureReason = workerFailureMessage(event)
93
109
  worker?.terminate()
94
110
  worker = undefined
@@ -101,9 +117,17 @@ export function createWorkerSamplingClient(): WorkerSamplingClient {
101
117
  worker = new Worker(new URL('../../core/workerSampling/workerRuntime.ts', import.meta.url), {
102
118
  type: 'module',
103
119
  })
104
- worker.onmessage = (event: MessageEvent<WorkerSamplingResponse>) => {
120
+ worker.onmessage = (
121
+ event: MessageEvent<
122
+ WorkerSamplingResponse | { type: 'request-error'; requestId: number; message: string }
123
+ >,
124
+ ) => {
105
125
  const request = pending.get(event.data.requestId)
106
126
  if (!request) return
127
+ if (event.data.type === 'request-error') {
128
+ fallBackToJavascript({ message: event.data.message } as ErrorEvent)
129
+ return
130
+ }
107
131
  pending.delete(event.data.requestId)
108
132
  request.resolve(event.data)
109
133
  }
@@ -119,8 +143,9 @@ export function createWorkerSamplingClient(): WorkerSamplingClient {
119
143
  return workerFailureReason
120
144
  },
121
145
  send(request) {
146
+ if (disposed) return Promise.reject(new Error('Sampling client was disposed.'))
122
147
  recordDatasetRequest(request)
123
- if (fallback) return Promise.resolve(fallbackRepository().handle(request))
148
+ if (fallback) return Promise.resolve().then(() => fallback!.handle(request))
124
149
  return new Promise<WorkerSamplingResponse>((resolve, reject) => {
125
150
  pending.set(request.requestId, { request, resolve, reject })
126
151
  try {
@@ -133,6 +158,13 @@ export function createWorkerSamplingClient(): WorkerSamplingClient {
133
158
  })
134
159
  },
135
160
  dispose() {
161
+ disposed = true
162
+ if (worker) {
163
+ worker.onmessage = null
164
+ worker.onerror = null
165
+ worker.onmessageerror = null
166
+ }
167
+ fallback?.handle({ type: 'dispose-all', requestId: 0 })
136
168
  worker?.terminate()
137
169
  worker = undefined
138
170
  pending.forEach(({ reject }) => reject(new Error('Sampling client was disposed.')))
@@ -0,0 +1,10 @@
1
+ import type { WaveformYAxisLabelTextStyle } from '../../types'
2
+
3
+ export function resolveYAxisLabelFontSize(style?: WaveformYAxisLabelTextStyle): number {
4
+ const size = style?.fontSize
5
+ return typeof size === 'number' && Number.isFinite(size) && size > 0 ? size : 12
6
+ }
7
+
8
+ export function resolveYAxisLabelBandWidth(style?: WaveformYAxisLabelTextStyle): number {
9
+ return resolveYAxisLabelFontSize(style) + 8
10
+ }