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
@@ -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
+ }
@@ -1,7 +1,6 @@
1
1
  import type { WaveformOverlayMode } from '../data/types'
2
2
  import {
3
3
  Y_AXIS_CHARACTER_WIDTH,
4
- Y_AXIS_LABEL_BAND_WIDTH,
5
4
  Y_AXIS_LABEL_GAP,
6
5
  Y_AXIS_OUTER_PADDING,
7
6
  Y_AXIS_TICK_PADDING,
@@ -9,6 +8,8 @@ import {
9
8
  import { axisTextMetrics, resolveRenderedYAxisSeriesGroups } from './layout'
10
9
  import type { DisplayTrack } from './types'
11
10
  import type { WaveformYDomain } from './yDomain'
11
+ import type { YAxisPaddingOptions } from './yAxisPadding'
12
+ import { resolveYAxisLabelBandWidth } from './yAxisLabelStyle'
12
13
 
13
14
  export interface YAxisLayoutMetrics {
14
15
  tickClearance: number
@@ -40,10 +41,13 @@ export function resolveYAxisLayoutMetrics(
40
41
  nice: boolean,
41
42
  tickCount: number,
42
43
  compact: boolean,
43
- upperPaddingRatio = 0,
44
+ padding?: YAxisPaddingOptions | number,
44
45
  showUnits = true,
45
46
  measureTextWidth?: (text: string) => number,
46
47
  ): YAxisLayoutMetrics {
48
+ const labelBandWidth = resolveYAxisLabelBandWidth(
49
+ typeof padding === 'object' ? padding.labelTextStyle : undefined,
50
+ )
47
51
  const axisText = chartTracks
48
52
  .filter((track) => track.series.length > 0)
49
53
  .flatMap((track) =>
@@ -53,7 +57,7 @@ export function resolveYAxisLayoutMetrics(
53
57
  yDomain,
54
58
  yDomains,
55
59
  viewportYDomains,
56
- upperPaddingRatio,
60
+ padding,
57
61
  ),
58
62
  )
59
63
  .map(
@@ -66,6 +70,8 @@ export function resolveYAxisLayoutMetrics(
66
70
  tickCount,
67
71
  compact,
68
72
  measureTextWidth,
73
+ group.maximumTick,
74
+ group.minimumTick,
69
75
  ).tickTextWidth,
70
76
  )
71
77
  const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText)
@@ -74,13 +80,9 @@ export function resolveYAxisLayoutMetrics(
74
80
  Y_AXIS_TICK_PADDING +
75
81
  tickTextWidth +
76
82
  Y_AXIS_LABEL_GAP +
77
- Y_AXIS_LABEL_BAND_WIDTH / 2
83
+ labelBandWidth / 2
78
84
  )
79
85
  const fullClearance =
80
- tickTextWidth +
81
- Y_AXIS_TICK_PADDING +
82
- Y_AXIS_LABEL_GAP +
83
- Y_AXIS_LABEL_BAND_WIDTH +
84
- Y_AXIS_OUTER_PADDING
86
+ tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + labelBandWidth + Y_AXIS_OUTER_PADDING
85
87
  return { tickClearance, fullClearance, labelCenterX }
86
88
  }
@@ -0,0 +1,60 @@
1
+ import type { WaveformAxesOptions } from '../../types'
2
+ import type { YAxisSeriesGroup } from './layout'
3
+
4
+ export type YAxisPaddingOptions = WaveformAxesOptions['y']
5
+
6
+ export function paddingRatio(enabled?: boolean, ratio?: number): number {
7
+ if (enabled === false) return 0
8
+ const value = ratio ?? (enabled === true ? 0.1 : 0)
9
+ return Number.isFinite(value) && value > 0 ? value : 0
10
+ }
11
+
12
+ export function applyYAxisPadding(
13
+ group: YAxisSeriesGroup,
14
+ viewport: [number, number] | undefined,
15
+ options: YAxisPaddingOptions | number,
16
+ ): YAxisSeriesGroup {
17
+ const config = typeof options === 'number' ? { upperPaddingRatio: options } : options
18
+ if (group.fixed) return withYAxisExtremaTicks(group, config?.extremaTicksVisible)
19
+ const upper = paddingRatio(config?.upperPaddingEnabled, config?.upperPaddingRatio)
20
+ const lower = paddingRatio(config?.lowerPaddingEnabled, config?.lowerPaddingRatio)
21
+ const [a, b] = group.domain
22
+ const span = b - a
23
+ const min = lower > 0 ? a - span * lower : a
24
+ const max = upper > 0 ? b + span * upper : b
25
+ return withYAxisExtremaTicks(
26
+ {
27
+ ...group,
28
+ domain: viewport ?? [Number.isFinite(min) ? min : a, Number.isFinite(max) ? max : b],
29
+ },
30
+ config?.extremaTicksVisible,
31
+ )
32
+ }
33
+
34
+ function withYAxisExtremaTicks(group: YAxisSeriesGroup, visible = true): YAxisSeriesGroup {
35
+ const maxima = group.seriesList.flatMap((series) =>
36
+ Number.isFinite(series.rawYMaximum) ? [series.rawYMaximum!] : [],
37
+ )
38
+ const minima = group.seriesList.flatMap((series) =>
39
+ Number.isFinite(series.rawYMinimum) ? [series.rawYMinimum!] : [],
40
+ )
41
+ return {
42
+ ...group,
43
+ maximumTick: visible && maxima.length ? Math.max(...maxima) : undefined,
44
+ minimumTick: visible && minima.length ? Math.min(...minima) : undefined,
45
+ }
46
+ }
47
+
48
+ export function withMaximumTick(values: number[], maximum?: number, minimum?: number): number[] {
49
+ if (minimum !== undefined && Number.isFinite(minimum)) {
50
+ values = [...values.filter((value) => !sameTick(value, minimum)), minimum].sort((a, b) => a - b)
51
+ }
52
+ if (maximum === undefined || !Number.isFinite(maximum)) return values
53
+ return [...values.filter((value) => !sameTick(value, maximum)), maximum].sort((a, b) => a - b)
54
+ }
55
+
56
+ export function sameTick(a: number, b: number): boolean {
57
+ return (
58
+ Math.abs(a - b) <= Number.EPSILON * 8 * Math.max(Math.abs(a), Math.abs(b), Number.MIN_VALUE)
59
+ )
60
+ }
@@ -1,23 +1,21 @@
1
1
  import { formatYAxisTickLayoutLabel } from './layout'
2
2
  import type { TrackLayout, WaveformYAxisLayout } from './types'
3
- import {
4
- Y_AXIS_CHARACTER_WIDTH,
5
- Y_AXIS_LABEL_BAND_WIDTH,
6
- Y_AXIS_TICK_PADDING,
7
- } from './yAxisConstants'
3
+ import type { WaveformYAxisLabelTextStyle } from '../../types'
4
+ import { resolveYAxisLabelBandWidth, resolveYAxisLabelFontSize } from './yAxisLabelStyle'
5
+ import { Y_AXIS_CHARACTER_WIDTH, Y_AXIS_TICK_PADDING } from './yAxisConstants'
8
6
 
9
- // Match the 12px channel names and 11px ticks in WaveformTrack.css.
10
- const TITLE_FONT_SIZE = 12
7
+ // Match the 11px ticks in WaveformTrack.css.
11
8
  const TICK_FONT_SIZE = 11
12
9
  const TEXT_GAP = 2
13
10
 
14
11
  interface TitleLayoutOptions {
12
+ labelTextStyle?: WaveformYAxisLabelTextStyle
15
13
  showUnits?: boolean
16
14
  compact?: boolean
17
15
  measureTextWidth?: (text: string) => number
18
16
  }
19
17
 
20
- function estimateTitleWidth(title: string): number {
18
+ function estimateTitleWidth(title: string, fontSize: number): number {
21
19
  return Array.from(title).reduce((width, character) => {
22
20
  // Latin labels average well below one em; full-width characters occupy one em.
23
21
  const em = /[\u2e80-\u9fff\uf900-\ufaff\uff01-\uff60]/u.test(character)
@@ -27,7 +25,7 @@ function estimateTitleWidth(title: string): number {
27
25
  : /[ilI1., :;|'!]/u.test(character)
28
26
  ? 0.4
29
27
  : 0.65
30
- return width + em * TITLE_FONT_SIZE
28
+ return width + em * fontSize
31
29
  }, 0)
32
30
  }
33
31
 
@@ -41,7 +39,8 @@ function titleDistance(
41
39
  ): number {
42
40
  const series = axis.seriesList[0]
43
41
  const title = series?.name.trim() || yLabel || ''
44
- const halfTitleHeight = estimateTitleWidth(title) / 2
42
+ const halfTitleHeight =
43
+ estimateTitleWidth(title, resolveYAxisLabelFontSize(options.labelTextStyle)) / 2
45
44
  const titleTop = track.height / 2 - halfTitleHeight - TEXT_GAP
46
45
  const titleBottom = track.height / 2 + halfTitleHeight + TEXT_GAP
47
46
  const lowestTick = Math.min(...axis.tickValues)
@@ -63,11 +62,11 @@ function titleDistance(
63
62
  )
64
63
  return [options.measureTextWidth?.(text) ?? text.length * Y_AXIS_CHARACTER_WIDTH]
65
64
  })
66
- // The 20px title band leaves 4px beside a 12px glyph.
65
+ // The title band leaves 4px beside each glyph.
67
66
  return (
68
67
  Math.max(Y_AXIS_CHARACTER_WIDTH, ...tickWidths) +
69
68
  Y_AXIS_TICK_PADDING +
70
- Y_AXIS_LABEL_BAND_WIDTH / 2
69
+ resolveYAxisLabelBandWidth(options.labelTextStyle) / 2
71
70
  )
72
71
  }
73
72
 
@@ -0,0 +1,43 @@
1
+ import { watch, type ComputedRef, type ShallowRef } from 'vue'
2
+ import type { ZoomTransform } from 'd3'
3
+
4
+ import type { TrackLayout } from '../core/types'
5
+
6
+ /** Keep X coordinates stable when toggling extrema changes the reserved Y-label width. */
7
+ export function useExtremaTickViewport(
8
+ visible: () => boolean,
9
+ innerWidth: ComputedRef<number>,
10
+ tracks: ComputedRef<TrackLayout[]>,
11
+ sharedTransform: ShallowRef<ZoomTransform>,
12
+ independentTransforms: ShallowRef<ZoomTransform[]>,
13
+ ) {
14
+ const resizeTransform = (transform: ZoomTransform, previous: number, next: number) =>
15
+ previous > 0 && next > 0
16
+ ? transform.translate(((next / previous - 1) * transform.x) / transform.k, 0)
17
+ : transform
18
+
19
+ watch(
20
+ () => ({
21
+ visible: visible(),
22
+ width: innerWidth.value,
23
+ tracks: tracks.value.map((track) => ({
24
+ id: track.id,
25
+ index: track.index,
26
+ width: track.width,
27
+ })),
28
+ }),
29
+ (next, previous) => {
30
+ if (next.visible === previous.visible) return
31
+ sharedTransform.value = resizeTransform(sharedTransform.value, previous.width, next.width)
32
+ const transforms = [...independentTransforms.value]
33
+ next.tracks.forEach((track) => {
34
+ const prior = previous.tracks.find((item) => item.id === track.id)
35
+ const transform = transforms[track.index]
36
+ if (prior && transform) {
37
+ transforms[track.index] = resizeTransform(transform, prior.width, track.width)
38
+ }
39
+ })
40
+ independentTransforms.value = transforms
41
+ },
42
+ )
43
+ }
@@ -13,6 +13,7 @@ import { createViewportPan } from './viewportPan'
13
13
  import { transformForDomain } from './zoomConstraints'
14
14
  import { emitBoxZoomIntent } from './zoomEventPayload'
15
15
  export interface ViewportContext {
16
+ clearBoundaries?: (trackIndex?: number) => void
16
17
  props: ResolvedWaveformChartProps
17
18
  emit: WaveformChartEmit
18
19
  selection: Ref<ViewportSelectionState | null>
@@ -104,8 +105,15 @@ export function useWaveformViewport(context: ViewportContext) {
104
105
  )
105
106
  const beginViewportDrag = (event: PointerEvent, trackIndex: number, independent: boolean) => {
106
107
  if (isPresentationMode.value) return
107
- const panRequested = props.pannable && spacePressed.value
108
- if ((!props.zoomable && !panRequested) || !isZoomMode.value || event.button !== 0) return
108
+ const panRequested =
109
+ props.pannable &&
110
+ (props.interactionMode === 'pan' || (isZoomMode.value && spacePressed.value))
111
+ if (
112
+ (!props.zoomable && !panRequested) ||
113
+ (!isZoomMode.value && !panRequested) ||
114
+ event.button !== 0
115
+ )
116
+ return
109
117
  const overlay = event.currentTarget as SVGRectElement
110
118
  const track = trackLayouts.value.find((item) => item.index === trackIndex)
111
119
  if (!track) return
@@ -291,6 +299,7 @@ export function useWaveformViewport(context: ViewportContext) {
291
299
  }
292
300
  }
293
301
  const resetViewport = (trackIndex?: number) => {
302
+ context.clearBoundaries?.(trackIndex)
294
303
  cancelPendingZoom()
295
304
  cancelViewportDrag()
296
305
  if (props.displayMode === 'independent' && trackIndex !== undefined) {
@@ -4,8 +4,7 @@
4
4
  }
5
5
 
6
6
  .waveform-track__y-axis-label-bg {
7
- fill: white;
8
- opacity: 0.9;
7
+ fill: transparent;
9
8
  pointer-events: none;
10
9
  }
11
10
 
@@ -20,15 +20,14 @@ interface Props {
20
20
  interactionMode?: WaveformInteractionMode
21
21
  frameNumber?: string | number
22
22
  frameStyle?: WaveformFrameStyle
23
+ framePath?: string
23
24
  axes?: WaveformAxesOptions
24
25
  timeUnit: 's' | 'ms'
25
26
  yLabel?: string
26
27
  showAxisUnits?: boolean
27
28
  showLabelUnits?: boolean
28
29
  hideYAxisTitles?: boolean
29
- containYAxisEndpoints?: boolean
30
30
  containFrameStroke?: boolean
31
- omitFrameTop?: boolean
32
31
  cleanView?: boolean
33
32
  zeroLine?: Required<WaveformZeroLineOptions>
34
33
  }
@@ -77,12 +76,6 @@ const resolvedFrameStyle = computed(() => {
77
76
  const frameInset = computed(() =>
78
77
  props.containFrameStroke ? resolvedFrameStyle.value.borderWidth / 2 : 0,
79
78
  )
80
- const openFramePath = computed(() => {
81
- const inset = frameInset.value
82
- const right = Math.max(inset, (props.track.width ?? props.innerWidth) - inset)
83
- const bottom = Math.max(inset, props.track.height - inset)
84
- return `M ${inset} ${inset} V ${bottom} H ${right} V ${inset}`
85
- })
86
79
  </script>
87
80
 
88
81
  <template>
@@ -106,6 +99,7 @@ const openFramePath = computed(() => {
106
99
  :clean-view="cleanView"
107
100
  :frame-number="frameNumber"
108
101
  :frame-style="resolvedFrameStyle"
102
+ show-empty-frame
109
103
  />
110
104
  <WaveformTrackAxes
111
105
  :track="track"
@@ -117,14 +111,13 @@ const openFramePath = computed(() => {
117
111
  :show-units="showAxisUnits"
118
112
  :show-label-units="showLabelUnits"
119
113
  :hide-y-axis-titles="hideYAxisTitles"
120
- :contain-y-axis-endpoints="containYAxisEndpoints || displayMode === 'compact'"
114
+ :hide-overlapping-maximum-label="displayMode === 'compact'"
121
115
  />
122
116
 
123
117
  <component
124
- :is="omitFrameTop ? 'path' : 'rect'"
125
- v-if="!track.isEmpty"
118
+ :is="framePath ? 'path' : 'rect'"
126
119
  class="waveform-track__plot-frame waveform-chart__plot-frame"
127
- :d="omitFrameTop ? openFramePath : undefined"
120
+ :d="framePath"
128
121
  :x="containFrameStroke ? frameInset : undefined"
129
122
  :y="containFrameStroke ? frameInset : undefined"
130
123
  :width="Math.max(0, (track.width ?? innerWidth) - frameInset * 2)"