waveform-analysis 0.1.65 → 0.1.67

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 (81) hide show
  1. package/README.md +146 -5
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +1326 -1118
  4. package/dist/style.css +1 -1
  5. package/dist/types/components/WaveformChart.vue.d.ts +7 -2
  6. package/dist/types/components/core/constants.d.ts +2 -0
  7. package/dist/types/components/core/grid.d.ts +1 -1
  8. package/dist/types/components/core/layout.d.ts +2 -2
  9. package/dist/types/components/core/pagination.d.ts +2 -0
  10. package/dist/types/components/core/title.d.ts +2 -1
  11. package/dist/types/components/core/trackLayoutBuilder.d.ts +5 -0
  12. package/dist/types/components/core/types.d.ts +2 -1
  13. package/dist/types/components/core/useWaveformChartController.d.ts +9 -0
  14. package/dist/types/components/core/useWaveformLayout.d.ts +1 -0
  15. package/dist/types/components/core/useWaveformPresentation.d.ts +3 -0
  16. package/dist/types/components/core/useWaveformYAxisLayout.d.ts +31 -0
  17. package/dist/types/components/core/waveformChartTypes.d.ts +11 -2
  18. package/dist/types/components/core/yAxisLayoutMetrics.d.ts +1 -1
  19. package/dist/types/components/core/yAxisTitleLayout.d.ts +4 -0
  20. package/dist/types/components/data/types.d.ts +1 -1
  21. package/dist/types/components/interaction/integerZoom.d.ts +12 -0
  22. package/dist/types/components/interaction/interactionTarget.d.ts +1 -0
  23. package/dist/types/components/interaction/remoteViewport.d.ts +8 -0
  24. package/dist/types/components/interaction/useWaveformViewport.d.ts +1 -2
  25. package/dist/types/components/interaction/viewportDomain.d.ts +1 -1
  26. package/dist/types/components/interaction/viewportPan.d.ts +4 -0
  27. package/dist/types/components/interaction/zoomConstraints.d.ts +2 -1
  28. package/dist/types/components/rendering/WaveformLegend.vue.d.ts +2 -0
  29. package/dist/types/components/rendering/WaveformTrack.vue.d.ts +6 -6
  30. package/dist/types/components/rendering/WaveformTrackAxes.vue.d.ts +5 -1
  31. package/dist/types/components/rendering/WaveformTrackBackdrop.vue.d.ts +0 -4
  32. package/dist/types/components/rendering/WaveformZeroLines.vue.d.ts +12 -0
  33. package/dist/types/data/recordedDampedWaveform.d.ts +1 -0
  34. package/dist/types/demo/types.d.ts +3 -0
  35. package/dist/types/index.d.ts +2 -1
  36. package/dist/types/types/chart.d.ts +17 -0
  37. package/dist/types/types/index.d.ts +1 -1
  38. package/package.json +1 -1
  39. package/src/App.vue +6 -0
  40. package/src/components/WaveformChart.css +5 -0
  41. package/src/components/WaveformChart.vue +3 -0
  42. package/src/components/WaveformChartView.vue +14 -3
  43. package/src/components/core/constants.ts +2 -0
  44. package/src/components/core/grid.ts +4 -1
  45. package/src/components/core/layout.ts +16 -2
  46. package/src/components/core/pagination.ts +17 -0
  47. package/src/components/core/title.ts +7 -3
  48. package/src/components/core/trackLayoutBuilder.ts +39 -59
  49. package/src/components/core/types.ts +2 -0
  50. package/src/components/core/useWaveformChartController.ts +17 -1
  51. package/src/components/core/useWaveformChartLifecycle.ts +11 -17
  52. package/src/components/core/useWaveformLayout.ts +67 -107
  53. package/src/components/core/useWaveformPresentation.ts +27 -3
  54. package/src/components/core/useWaveformYAxisLayout.ts +140 -0
  55. package/src/components/core/waveformChartTypes.ts +15 -0
  56. package/src/components/core/yAxisLayoutMetrics.ts +11 -2
  57. package/src/components/core/yAxisTitleLayout.ts +78 -0
  58. package/src/components/data/types.ts +3 -0
  59. package/src/components/interaction/integerZoom.ts +64 -0
  60. package/src/components/interaction/interactionTarget.ts +10 -0
  61. package/src/components/interaction/remoteViewport.ts +53 -0
  62. package/src/components/interaction/useWaveformViewport.ts +16 -58
  63. package/src/components/interaction/useWaveformZoom.ts +13 -11
  64. package/src/components/interaction/viewportDomain.ts +11 -3
  65. package/src/components/interaction/viewportPan.ts +82 -0
  66. package/src/components/interaction/zoomConstraints.ts +7 -5
  67. package/src/components/rendering/WaveformLegend.vue +10 -1
  68. package/src/components/rendering/WaveformTrack.vue +24 -6
  69. package/src/components/rendering/WaveformTrackAxes.vue +16 -5
  70. package/src/components/rendering/WaveformTrackBackdrop.vue +2 -34
  71. package/src/components/rendering/WaveformZeroLines.vue +53 -0
  72. package/src/data/recordedDampedWaveform.ts +3 -0
  73. package/src/data/simulatedWaveforms.ts +12 -10
  74. package/src/demo/DemoChartHost.vue +1 -0
  75. package/src/demo/DemoGridFrameControls.vue +12 -0
  76. package/src/demo/DemoViewControls.vue +4 -0
  77. package/src/demo/types.ts +3 -0
  78. package/src/index.ts +4 -0
  79. package/src/styles.css +3 -3
  80. package/src/types/chart.ts +20 -0
  81. package/src/types/index.ts +3 -0
@@ -27,7 +27,6 @@ import {
27
27
  } from './grid'
28
28
  import {
29
29
  axisTextMetrics,
30
- formatYAxisTickLabel,
31
30
  resolveRenderedYAxisSeriesGroups,
32
31
  resolveYAxisTicks,
33
32
  resolveYAxisTickCount,
@@ -35,9 +34,15 @@ import {
35
34
  } from './layout'
36
35
  import type { DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
37
36
  import { applyXDomainStrategy } from './xDomain'
37
+ import { alignLeftYAxisTitles } from './yAxisTitleLayout'
38
+ import {
39
+ alignIntegerZoomDomain,
40
+ normalizeIntegerZoomDomain,
41
+ integerZoomTicks,
42
+ formatIntegerZoomLabel,
43
+ } from '../interaction/integerZoom'
38
44
  import {
39
45
  Y_AXIS_LABEL_BAND_WIDTH,
40
- Y_AXIS_CHARACTER_WIDTH,
41
46
  Y_AXIS_LABEL_GAP,
42
47
  Y_AXIS_OUTER_PADDING,
43
48
  Y_AXIS_RIGHT_LABEL_OFFSET,
@@ -58,6 +63,7 @@ export interface BuildTrackLayoutsOptions {
58
63
  initialXDomain?: [number, number]
59
64
  initialXDomains?: Record<string, [number, number]>
60
65
  xDomainStrategy?: WaveformXDomainStrategy
66
+ integerZoom?: boolean
61
67
  fixedYDomain?: [number, number]
62
68
  fixedYDomains?: Record<string, [number, number]>
63
69
  yDomains?: Record<string, [number, number]>
@@ -65,6 +71,7 @@ export interface BuildTrackLayoutsOptions {
65
71
  xAxisLabelFormatter?: WaveformXAxisLabelFormatter
66
72
  yAxisSplitNumber?: number
67
73
  yAxisNice?: boolean
74
+ yAxisUpperPaddingRatio?: number
68
75
  rendering: ResolvedWaveformRenderingOptions
69
76
  /** Latest sampling result for SVG lines only; source series remain complete for interaction. */
70
77
  linePointOverrides?: Readonly<Record<string, WaveformPoint[]>>
@@ -73,6 +80,9 @@ export interface BuildTrackLayoutsOptions {
73
80
  yLabel?: string
74
81
  yAxisSlots?: readonly YAxisSlot[]
75
82
  showCompactEmptyTracks: boolean
83
+ useNonEmptyBottomTracks?: boolean
84
+ compactYAxisLayout?: boolean
85
+ showAxisUnits?: boolean
76
86
  }
77
87
 
78
88
  function resolveIndependentXDomain(
@@ -85,13 +95,21 @@ function resolveIndependentXDomain(
85
95
  options.initialXDomains?.[track.id] ??
86
96
  options.initialXDomains?.[seriesId] ??
87
97
  options.initialXDomain
88
- return explicitDomain
89
- ? applyXDomainStrategy(explicitDomain, strategy, true)
90
- : applyXDomainStrategy(track.xDomain, strategy)
98
+ return alignIntegerZoomDomain(
99
+ explicitDomain
100
+ ? applyXDomainStrategy(explicitDomain, strategy, true)
101
+ : applyXDomainStrategy(track.xDomain, strategy),
102
+ options,
103
+ )
91
104
  }
92
105
 
93
106
  export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayout[] {
94
- const visibleCells = options.cells.map((cell) => ({ ...cell, hasSeries: Boolean(cell.series) }))
107
+ const visibleCells = options.cells.map((cell) => ({
108
+ ...cell,
109
+ hasSeries: options.useNonEmptyBottomTracks
110
+ ? Boolean(cell.series?.series.length)
111
+ : Boolean(cell.series),
112
+ }))
95
113
  const bottomCells = getBottomRowCellIndexes(visibleCells, options.grid.columnCount)
96
114
 
97
115
  const layouts: TrackLayout[] = visibleCells.flatMap((cell, index) => {
@@ -119,12 +137,14 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
119
137
  ? (options.independentTransforms[index] ?? zoomIdentity)
120
138
  : zoomIdentity
121
139
  const xScale = transform.rescaleX(baseXScale)
140
+ xScale.domain(normalizeIntegerZoomDomain(xScale.domain() as [number, number], options))
122
141
  const yAxisGroups = resolveRenderedYAxisSeriesGroups(
123
142
  displayTrack,
124
143
  options.overlayMode,
125
144
  options.fixedYDomain,
126
145
  options.fixedYDomains,
127
146
  options.yDomains,
147
+ options.yAxisUpperPaddingRatio,
128
148
  )
129
149
  const sideIndexes = { left: 0, right: 0 }
130
150
  const sideOffsets = { left: 0, right: 0 }
@@ -141,7 +161,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
141
161
  scale.domain() as [number, number],
142
162
  false,
143
163
  tickValues,
144
- group.seriesList[0]?.unit,
164
+ options.showAxisUnits !== false ? group.seriesList[0]?.unit : undefined,
145
165
  tickCount,
146
166
  )
147
167
  const clearance =
@@ -164,7 +184,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
164
184
  (slot
165
185
  ? (group.side === 'left' ? 0 : cell.width) + slot.labelOffset
166
186
  : x + (group.side === 'left' ? -labelDistance : labelDistance)) -
167
- (group.side === 'right' ? Y_AXIS_RIGHT_LABEL_OFFSET : 0)
187
+ (group.side === 'right' && !options.compactYAxisLayout ? Y_AXIS_RIGHT_LABEL_OFFSET : 0)
168
188
  if (!slot) sideOffsets[group.side] += clearance
169
189
  return {
170
190
  index: group.index,
@@ -181,25 +201,19 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
181
201
  const fallbackYScale = scaleLinear(displayTrack.yDomain, [cell.plotHeight, 0])
182
202
  if (options.yAxisNice !== false) fallbackYScale.nice()
183
203
  const yScale = yAxes[0]?.scale ?? fallbackYScale
184
- const xMajorTicks = xScale.ticks(Math.max(2, Math.floor(cell.width / 100)))
204
+ const xMajorTicks = integerZoomTicks(
205
+ xScale.domain() as [number, number],
206
+ Math.max(2, Math.floor(cell.width / 100)),
207
+ options,
208
+ )
209
+ const xAxisLabelFormatter =
210
+ options.xAxisLabelFormatter ?? (options.integerZoom ? formatIntegerZoomLabel : undefined)
185
211
  const yMajorTicks = yAxes[0]?.majorTicks ?? []
186
212
  const yAxisTickValues = yAxes[0]?.tickValues ?? []
187
213
  const domain = xScale.domain() as [number, number]
188
214
  const endpointLabels = {
189
- start: formatXAxisLabel(
190
- domain[0],
191
- domain,
192
- options.timeUnit,
193
- 'start',
194
- options.xAxisLabelFormatter,
195
- ),
196
- end: formatXAxisLabel(
197
- domain[1],
198
- domain,
199
- options.timeUnit,
200
- 'end',
201
- options.xAxisLabelFormatter,
202
- ),
215
+ start: formatXAxisLabel(domain[0], domain, options.timeUnit, 'start', xAxisLabelFormatter),
216
+ end: formatXAxisLabel(domain[1], domain, options.timeUnit, 'end', xAxisLabelFormatter),
203
217
  }
204
218
  const leftClearance = endpointLabels.start.length * 7 + 10
205
219
  const rightClearance = endpointLabels.end.length * 7 + 10
@@ -262,6 +276,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
262
276
  yScale,
263
277
  yAxes,
264
278
  xMajorTicks,
279
+ xAxisLabelFormatter,
265
280
  xMinorTicks: buildMinorTicks(xMajorTicks, 3, domain),
266
281
  yMajorTicks,
267
282
  yMinorTicks: yAxes[0]?.minorTicks ?? [],
@@ -283,41 +298,6 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
283
298
  }
284
299
  })
285
300
 
286
- // Align names using the widest nearby tick text in each column and left-axis slot.
287
- const titleDistances = new Map<string, number>()
288
- for (const track of layouts) {
289
- let leftIndex = 0
290
- for (const axis of track.yAxes) {
291
- if (axis.side !== 'left') continue
292
- const key = `${track.column}:${leftIndex++}`
293
- const series = axis.seriesList[0]
294
- const title = series?.name.trim() || options.yLabel || ''
295
- const halfTitleHeight = title.length * 8 + 6
296
- const nearbyTicks = axis.tickValues.filter(
297
- (value) => Math.abs(axis.scale(value) - track.height / 2) <= halfTitleHeight,
298
- )
299
- const tickWidth = Math.max(
300
- Y_AXIS_CHARACTER_WIDTH,
301
- ...nearbyTicks.map(
302
- (value) =>
303
- formatYAxisTickLabel(
304
- value,
305
- axis.scale.domain() as [number, number],
306
- axis.tickValues,
307
- series?.unit,
308
- ).length * Y_AXIS_CHARACTER_WIDTH,
309
- ),
310
- )
311
- const distance = tickWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_BAND_WIDTH / 2
312
- titleDistances.set(key, Math.max(titleDistances.get(key) ?? 0, distance))
313
- }
314
- }
315
- for (const track of layouts) {
316
- let leftIndex = 0
317
- for (const axis of track.yAxes) {
318
- if (axis.side !== 'left') continue
319
- axis.labelX = axis.x - titleDistances.get(`${track.column}:${leftIndex++}`)!
320
- }
321
- }
301
+ if (!options.compactYAxisLayout) alignLeftYAxisTitles(layouts, options.yLabel)
322
302
  return layouts
323
303
  }
@@ -5,6 +5,7 @@ import type {
5
5
  WaveformLineStyle,
6
6
  WaveformPoint,
7
7
  WaveformPointType,
8
+ WaveformXAxisLabelFormatter,
8
9
  } from '../../types'
9
10
  import type { WaveformPointSource } from '../../core/waveformPointSource'
10
11
  import type { NormalizedWaveformGridLineOptions } from './grid'
@@ -114,6 +115,7 @@ export interface TrackLayout {
114
115
  yMinorTicks: number[]
115
116
  yAxisTickValues: number[]
116
117
  xAxisTickValues: number[]
118
+ xAxisLabelFormatter?: WaveformXAxisLabelFormatter
117
119
  endpointLabels: { start: string; end: string }
118
120
  path: string | null
119
121
  seriesPaths: TrackSeriesPath[]
@@ -1,5 +1,6 @@
1
1
  import { zoomIdentity, type ZoomTransform } from 'd3'
2
2
  import {
3
+ computed,
3
4
  reactive,
4
5
  markRaw,
5
6
  ref,
@@ -21,6 +22,7 @@ import { useWaveformZoom } from '../interaction/useWaveformZoom'
21
22
  import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
22
23
  import { margin } from './constants'
23
24
  import { getPageSize } from './grid'
25
+ import { resolveCompactPaginationBand } from './pagination'
24
26
  import type { WaveformHoverState } from './types'
25
27
  import { useWaveformChartLifecycle } from './useWaveformChartLifecycle'
26
28
  import { usePreparedWaveformSeries } from './useWaveformData'
@@ -165,9 +167,22 @@ export function useWaveformChartController(
165
167
  linePointOverrides,
166
168
  })
167
169
 
170
+ const paginationVisible = computed(
171
+ () => gridOptions.value.showPagination && pageCount.value > 1 && !isCleanView.value,
172
+ )
168
173
  watchEffect(() => {
169
174
  paginationBandHeight.value =
170
- gridOptions.value.showPagination && pageCount.value > 1 && chartWidth.value <= 520 ? 40 : 0
175
+ props.layoutPreset === 'edge-compact'
176
+ ? resolveCompactPaginationBand(
177
+ paginationVisible.value,
178
+ chartWidth.value,
179
+ pageCount.value,
180
+ resolvedChartLeftMargin.value + innerWidth.value / 2,
181
+ layout.resolvedXLabel.value,
182
+ )
183
+ : gridOptions.value.showPagination && pageCount.value > 1 && chartWidth.value <= 520
184
+ ? 40
185
+ : 0
171
186
  })
172
187
 
173
188
  const zoom = useWaveformZoom({
@@ -306,6 +321,7 @@ export function useWaveformChartController(
306
321
  return reactive({
307
322
  ...toRefs(props),
308
323
  ...presentation,
324
+ paginationVisible,
309
325
  ...layout,
310
326
  ...annotations,
311
327
  ...viewport,
@@ -13,7 +13,9 @@ import type { AnnotationSeriesCandidate, useWaveformAnnotationInteraction } from
13
13
  import type { NormalizedWaveformGridOptions } from './grid'
14
14
  import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
15
15
  import type { ResolvedWaveformChartProps, WaveformChartEmit } from './waveformChartTypes'
16
- import { constrainZoomDomain, transformForDomain } from '../interaction/zoomConstraints'
16
+ import { transformForDomain } from '../interaction/zoomConstraints'
17
+ import { remotePanBoundary, restoreViewportDomain } from '../interaction/remoteViewport'
18
+ import { isEditableTarget } from '../interaction/interactionTarget'
17
19
  import { seriesIdentity } from '../interaction/zoomEventPayload'
18
20
 
19
21
  interface LifecycleContext {
@@ -61,17 +63,6 @@ interface LifecycleContext {
61
63
  clearZoomBindings: () => void
62
64
  }
63
65
 
64
- function isEditableTarget(target: EventTarget | null): boolean {
65
- return (
66
- target instanceof Element &&
67
- Boolean(
68
- target.closest(
69
- 'button, input, select, textarea, [contenteditable]:not([contenteditable="false"])',
70
- ),
71
- )
72
- )
73
- }
74
-
75
66
  export function useWaveformChartLifecycle(context: LifecycleContext) {
76
67
  const {
77
68
  props,
@@ -161,7 +152,8 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
161
152
  trackLayouts.value.flatMap((track) => {
162
153
  const current = track.xScale.domain() as [number, number]
163
154
  const boundary = resolveInitialTrackDomain(track)
164
- return current[1] - current[0] < boundary[1] - boundary[0] - 1e-12
155
+ return remotePanBoundary(props) ||
156
+ current[1] - current[0] < boundary[1] - boundary[0] - 1e-12
165
157
  ? [[seriesIdentity(track.seriesList.map((series) => series.id)), current]]
166
158
  : []
167
159
  }),
@@ -172,7 +164,9 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
172
164
  const current = sharedZoomDomain.value
173
165
  const boundary = initialXDomain.value
174
166
  pendingSharedXDomain =
175
- current[1] - current[0] < boundary[1] - boundary[0] - 1e-12 ? [...current] : undefined
167
+ remotePanBoundary(props) || current[1] - current[0] < boundary[1] - boundary[0] - 1e-12
168
+ ? [...current]
169
+ : undefined
176
170
  }
177
171
 
178
172
  function handleDataReferenceChange() {
@@ -193,7 +187,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
193
187
  )
194
188
  if (!previousDomain) return
195
189
  const boundary = resolveInitialTrackDomain(track)
196
- const domain = constrainZoomDomain(previousDomain, boundary, [track.seriesList], props)
190
+ const domain = restoreViewportDomain(previousDomain, boundary, [track.seriesList], props)
197
191
  nextTransforms[track.index] = transformForDomain(domain, boundary, track.width)
198
192
  })
199
193
  independentTransforms.value = nextTransforms
@@ -203,7 +197,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
203
197
  const groups = trackLayouts.value
204
198
  .filter((track) => track.hasVisibleSeries)
205
199
  .map((track) => track.seriesList)
206
- const domain = constrainZoomDomain(pendingSharedXDomain, boundary, groups, props)
200
+ const domain = restoreViewportDomain(pendingSharedXDomain, boundary, groups, props)
207
201
  sharedTransform.value = transformForDomain(domain, boundary, innerWidth.value)
208
202
  pendingSharedXDomain = undefined
209
203
  }
@@ -239,7 +233,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
239
233
  )
240
234
 
241
235
  watch(
242
- () => props.displayMode,
236
+ () => [props.displayMode, props.integerZoom, props.integerZoom ? props.timeUnit : undefined],
243
237
  () => {
244
238
  const previousPage = currentPage.value
245
239
  currentPage.value = 1
@@ -7,21 +7,17 @@ import {
7
7
  type AnnotationSeriesInfo,
8
8
  type AnnotationTrackLayout,
9
9
  } from '../annotation'
10
- import { channelColors, margin, MINIMUM_PLOT_WIDTH } from './constants'
11
- import {
12
- getGridGap,
13
- getPageCount,
14
- normalizeGridOptions,
15
- paginateSeries,
16
- resolveGridCellGeometry,
17
- } from './grid'
18
- import { buildTrackLayouts, buildYAxisSlots, resolveYAxisTickCount } from './layout'
10
+ import { channelColors } from './constants'
11
+ import { getPageCount, normalizeGridOptions, paginateSeries, resolveGridCellGeometry } from './grid'
12
+ import { buildTrackLayouts, resolveYAxisTickCount } from './layout'
19
13
  import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
20
14
  import { createFrameNumberResolver, resolvePageableTracks } from './trackPagination'
21
15
  import type { PreparedWaveformSeries } from './useWaveformData'
22
16
  import type { ResolvedWaveformChartProps } from './waveformChartTypes'
23
17
  import { applyXDomainStrategy } from './xDomain'
24
- import { resolveYAxisLayoutMetrics, resolveViewportYDomains } from './yAxisLayoutMetrics'
18
+ import { alignIntegerZoomDomain, normalizeIntegerZoomDomain } from '../interaction/integerZoom'
19
+ import { resolveViewportYDomains } from './yAxisLayoutMetrics'
20
+ import { useWaveformYAxisLayout } from './useWaveformYAxisLayout'
25
21
  import type { useWaveformAnnotationInteraction } from '../annotation'
26
22
  interface LayoutContext {
27
23
  props: ResolvedWaveformChartProps
@@ -94,6 +90,11 @@ export function useWaveformLayout(context: LayoutContext) {
94
90
  }
95
91
  })
96
92
  })
93
+ const showLegendUnits = computed(
94
+ () =>
95
+ props.unitDisplayMode === 'legend-single-series' &&
96
+ !chartTracks.value.some((track) => track.series.length > 1),
97
+ )
97
98
  const renderingOptions = computed(() => resolveWaveformRenderingOptions(props.rendering))
98
99
  const pageableTracks = computed(() =>
99
100
  resolvePageableTracks(chartTracks.value, gridOptions.value.hideEmptyTracks),
@@ -119,86 +120,21 @@ export function useWaveformLayout(context: LayoutContext) {
119
120
  independentYDomains.value,
120
121
  ),
121
122
  )
122
- const yAxisMetrics = computed(() =>
123
- resolveYAxisLayoutMetrics(
124
- chartTracks.value,
125
- props.overlayMode,
126
- props.yDomain,
127
- props.yDomains,
128
- viewportYDomains.value,
129
- props.axes?.y?.nice !== false,
130
- yAxisTickCount.value,
131
- props.displayMode === 'compact',
132
- ),
133
- )
134
- const hasYAxisLabels = computed(() =>
135
- chartTracks.value.some(
136
- (track) =>
137
- track.visibleSeries.length === 1 &&
138
- Boolean(track.visibleSeries[0]?.name.trim() || props.yLabel),
139
- ),
140
- )
141
- const hasVisibleWaveformData = computed(() =>
142
- chartTracks.value.some((track) => track.visibleSeries.length > 0),
143
- )
144
- const chartLeftMargin = computed(() =>
145
- Math.max(
146
- margin.left,
147
- hasYAxisLabels.value
148
- ? yAxisMetrics.value.fullClearance
149
- : hasVisibleWaveformData.value
150
- ? yAxisMetrics.value.tickClearance
151
- : 0,
152
- ),
153
- )
154
- const yAxisSlots = computed(() =>
155
- buildYAxisSlots(
156
- layoutTracks.value.filter((track) => track.visibleSeries.length > 0),
157
- props.overlayMode,
158
- props.yDomain,
159
- props.yDomains,
160
- yAxisTickCount.value,
161
- props.axes?.y?.nice !== false,
162
- props.displayMode === 'compact',
163
- viewportYDomains.value,
164
- ),
165
- )
166
- const resolvedChartLeftMargin = computed(() =>
167
- props.overlayMode === 'multi-axis'
168
- ? Math.max(chartLeftMargin.value, yAxisSlots.value.clearance.left)
169
- : chartLeftMargin.value,
170
- )
171
- const chartRightMargin = computed(() =>
172
- props.overlayMode === 'multi-axis'
173
- ? Math.max(margin.right, yAxisSlots.value.clearance.right)
174
- : margin.right,
175
- )
176
- const innerWidth = computed(() =>
177
- Math.max(0, chartWidth.value - resolvedChartLeftMargin.value - chartRightMargin.value),
178
- )
179
- const yAxisLayout = computed(() => {
180
- const baseGap = getGridGap(props.displayMode)
181
- const columnCount = gridOptions.value.columnCount
182
- const hasMultipleColumns = columnCount > 1
183
- const fullGap = Math.max(baseGap, yAxisMetrics.value.fullClearance)
184
- const tickGap = Math.max(baseGap, yAxisMetrics.value.tickClearance)
185
- const plotWidth = (innerWidth.value - fullGap * Math.max(0, columnCount - 1)) / columnCount
186
- const canReserveLabelClearance = plotWidth >= MINIMUM_PLOT_WIDTH
187
- return {
188
- horizontalGap:
189
- props.overlayMode === 'multi-axis' && hasMultipleColumns && hasVisibleWaveformData.value
190
- ? Math.max(baseGap, yAxisSlots.value.clearance.left + yAxisSlots.value.clearance.right)
191
- : hasMultipleColumns && hasVisibleWaveformData.value
192
- ? hasYAxisLabels.value && canReserveLabelClearance
193
- ? fullGap
194
- : tickGap
195
- : baseGap,
196
- hideSecondaryLabels:
197
- props.overlayMode !== 'multi-axis' &&
198
- hasMultipleColumns &&
199
- hasYAxisLabels.value &&
200
- !canReserveLabelClearance,
201
- }
123
+ const {
124
+ yAxisMetrics,
125
+ yAxisSlots,
126
+ resolvedChartLeftMargin,
127
+ innerWidth,
128
+ yAxisLayout,
129
+ hasVisibleWaveformData,
130
+ } = useWaveformYAxisLayout({
131
+ props,
132
+ chartTracks,
133
+ layoutTracks,
134
+ chartWidth,
135
+ gridOptions,
136
+ viewportYDomains,
137
+ yAxisTickCount,
202
138
  })
203
139
  const hasWaveformData = computed(() => chartSeries.value.length > 0)
204
140
  const hasChartArea = computed(() => innerWidth.value > 0 && innerHeight.value > 0)
@@ -220,13 +156,19 @@ export function useWaveformLayout(context: LayoutContext) {
220
156
  Number.isFinite(domain[1]) &&
221
157
  domain[0] !== domain[1]
222
158
  ) {
223
- return applyXDomainStrategy(
224
- domain[0] < domain[1] ? domain : [domain[1], domain[0]],
225
- props.xDomainStrategy,
226
- true,
159
+ return alignIntegerZoomDomain(
160
+ applyXDomainStrategy(
161
+ domain[0] < domain[1] ? domain : [domain[1], domain[0]],
162
+ props.xDomainStrategy,
163
+ true,
164
+ ),
165
+ props,
227
166
  )
228
167
  }
229
- return applyXDomainStrategy(sharedXDomain.value, props.xDomainStrategy)
168
+ return alignIntegerZoomDomain(
169
+ applyXDomainStrategy(sharedXDomain.value, props.xDomainStrategy),
170
+ props,
171
+ )
230
172
  })
231
173
  const resolveInitialTrackDomain = (track: TrackLayout): [number, number] => {
232
174
  const configuredDomain =
@@ -239,24 +181,32 @@ export function useWaveformLayout(context: LayoutContext) {
239
181
  Number.isFinite(configuredDomain[1]) &&
240
182
  configuredDomain[0] !== configuredDomain[1]
241
183
  ) {
242
- return applyXDomainStrategy(
243
- configuredDomain[0] < configuredDomain[1]
244
- ? configuredDomain
245
- : [configuredDomain[1], configuredDomain[0]],
246
- props.xDomainStrategy,
247
- true,
184
+ return alignIntegerZoomDomain(
185
+ applyXDomainStrategy(
186
+ configuredDomain[0] < configuredDomain[1]
187
+ ? configuredDomain
188
+ : [configuredDomain[1], configuredDomain[0]],
189
+ props.xDomainStrategy,
190
+ true,
191
+ ),
192
+ props,
248
193
  )
249
194
  }
250
- return applyXDomainStrategy(
251
- paddedDomain(track.seriesList.flatMap((series) => series.xDomain)),
252
- props.xDomainStrategy,
195
+ return alignIntegerZoomDomain(
196
+ applyXDomainStrategy(
197
+ paddedDomain(track.seriesList.flatMap((series) => series.xDomain)),
198
+ props.xDomainStrategy,
199
+ ),
200
+ props,
253
201
  )
254
202
  }
255
- const sharedZoomDomain = computed(
256
- () =>
203
+ const sharedZoomDomain = computed(() =>
204
+ normalizeIntegerZoomDomain(
257
205
  sharedTransform.value
258
206
  .rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value]))
259
207
  .domain() as [number, number],
208
+ props,
209
+ ),
260
210
  )
261
211
  const gridCells = computed(() => {
262
212
  const cells = resolveGridCellGeometry(
@@ -264,8 +214,12 @@ export function useWaveformLayout(context: LayoutContext) {
264
214
  innerHeight.value,
265
215
  gridOptions.value,
266
216
  props.displayMode,
267
- layoutTracks.value.map(Boolean),
217
+ layoutTracks.value.map((track) =>
218
+ props.layoutPreset === 'edge-compact' ? track.series.length > 0 : Boolean(track),
219
+ ),
268
220
  yAxisLayout.value.horizontalGap,
221
+ true,
222
+ props.layoutPreset === 'edge-compact' && !isCleanView.value,
269
223
  )
270
224
  return cells.map((cell, index) => ({ ...cell, series: layoutTracks.value[index] }))
271
225
  })
@@ -273,6 +227,9 @@ export function useWaveformLayout(context: LayoutContext) {
273
227
  buildTrackLayouts({
274
228
  cells: gridCells.value,
275
229
  grid: gridOptions.value,
230
+ useNonEmptyBottomTracks: props.layoutPreset === 'edge-compact',
231
+ compactYAxisLayout: props.layoutPreset === 'edge-compact',
232
+ showAxisUnits: props.layoutPreset !== 'edge-compact' || props.unitDisplayMode === 'axis',
276
233
  displayMode: props.displayMode,
277
234
  overlayMode: props.overlayMode,
278
235
  independentTransforms: independentTransforms.value,
@@ -280,6 +237,7 @@ export function useWaveformLayout(context: LayoutContext) {
280
237
  initialXDomain: props.initialXDomain ? initialXDomain.value : undefined,
281
238
  initialXDomains: props.initialXDomains,
282
239
  xDomainStrategy: props.xDomainStrategy,
240
+ integerZoom: props.integerZoom,
283
241
  fixedYDomain: props.yDomain,
284
242
  fixedYDomains: props.yDomains,
285
243
  yDomains: viewportYDomains.value,
@@ -287,6 +245,7 @@ export function useWaveformLayout(context: LayoutContext) {
287
245
  xAxisLabelFormatter: props.axes?.x?.labelFormatter,
288
246
  yAxisSplitNumber: props.axes?.y?.splitNumber,
289
247
  yAxisNice: props.axes?.y?.nice,
248
+ yAxisUpperPaddingRatio: props.axes?.y?.upperPaddingRatio,
290
249
  rendering: renderingOptions.value,
291
250
  linePointOverrides: linePointOverrides?.value,
292
251
  hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
@@ -342,6 +301,7 @@ export function useWaveformLayout(context: LayoutContext) {
342
301
  return {
343
302
  chartSeries,
344
303
  chartTracks,
304
+ showLegendUnits,
345
305
  pageableTracks,
346
306
  renderingOptions,
347
307
  gridOptions,
@@ -53,6 +53,8 @@ export function useWaveformPresentation(context: PresentationContext) {
53
53
  const isPresentationMode = computed(() => props.presentationMode === true)
54
54
  const resolvedZeroLine = computed(() => {
55
55
  const width = props.zeroLine.width
56
+ const opacity = props.zeroLine.opacity
57
+ const threshold = props.zeroLine.boundaryThreshold
56
58
  return {
57
59
  visible: props.zeroLine.visible !== false,
58
60
  color: props.zeroLine.color || ZERO_LINE_DEFAULTS.COLOR,
@@ -61,6 +63,14 @@ export function useWaveformPresentation(context: PresentationContext) {
61
63
  ? width
62
64
  : ZERO_LINE_DEFAULTS.WIDTH,
63
65
  dash: props.zeroLine.dash ?? ZERO_LINE_DEFAULTS.DASH,
66
+ opacity:
67
+ typeof opacity === 'number' && Number.isFinite(opacity) && opacity >= 0 && opacity <= 1
68
+ ? opacity
69
+ : ZERO_LINE_DEFAULTS.OPACITY,
70
+ boundaryThreshold:
71
+ typeof threshold === 'number' && Number.isFinite(threshold) && threshold >= 0
72
+ ? threshold
73
+ : ZERO_LINE_DEFAULTS.BOUNDARY_THRESHOLD,
64
74
  }
65
75
  })
66
76
  const legendBackgroundColor = computed(
@@ -83,6 +93,7 @@ export function useWaveformPresentation(context: PresentationContext) {
83
93
  return position === 'top' || position === 'bottom' ? 'horizontal' : 'vertical'
84
94
  }
85
95
 
96
+ const isEdgeCompact = computed(() => props.layoutPreset === 'edge-compact')
86
97
  const resolvedTitleText = computed(() => props.title?.text.trim() ?? '')
87
98
  const titleAreaReserved = computed(
88
99
  () =>
@@ -136,10 +147,17 @@ export function useWaveformPresentation(context: PresentationContext) {
136
147
  }))
137
148
  const titleLayout = computed(() =>
138
149
  calculateRotatedTitleLayout({
139
- naturalWidth: measuredTitleWidth.value || estimatedTitleWidth.value,
150
+ naturalWidth:
151
+ isEdgeCompact.value && !titleIsRotated.value && measuredTitleHeight.value > 0
152
+ ? Math.min(
153
+ measuredTitleWidth.value || titleAvailableWidth.value,
154
+ titleAvailableWidth.value,
155
+ )
156
+ : measuredTitleWidth.value || estimatedTitleWidth.value,
140
157
  naturalHeight: measuredTitleHeight.value || titleFontSize.value * TITLE_LINE_HEIGHT,
141
158
  availableWidth: titleAvailableWidth.value,
142
159
  rotation: titleRotation.value,
160
+ edgeCompact: isEdgeCompact.value,
143
161
  }),
144
162
  )
145
163
  const titleAreaHeight = computed(() =>
@@ -149,13 +167,18 @@ export function useWaveformPresentation(context: PresentationContext) {
149
167
  typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
150
168
  const resolvedPlotMargin = computed(() => ({
151
169
  top: resolvePlotMargin(props.plotMargin.top, margin.top),
152
- bottom: resolvePlotMargin(props.plotMargin.bottom, margin.bottom),
170
+ bottom: Math.max(
171
+ isEdgeCompact.value ? 44 : 0,
172
+ resolvePlotMargin(props.plotMargin.bottom, margin.bottom),
173
+ ),
153
174
  }))
154
175
  const chartTopMargin = computed(() => resolvedPlotMargin.value.top)
155
176
  const drawingHeight = computed(() =>
156
177
  Math.max(0, chartHeight.value - titleAreaHeight.value - paginationBandHeight.value),
157
178
  )
158
- const xAxisTitleY = computed(() => Math.max(0, drawingHeight.value - X_AXIS_TITLE_BOTTOM_OFFSET))
179
+ const xAxisTitleY = computed(() =>
180
+ Math.max(0, drawingHeight.value - X_AXIS_TITLE_BOTTOM_OFFSET + (isEdgeCompact.value ? 3 : 0)),
181
+ )
159
182
  const innerHeight = computed(() =>
160
183
  Math.max(
161
184
  0,
@@ -192,6 +215,7 @@ export function useWaveformPresentation(context: PresentationContext) {
192
215
  chartHeight,
193
216
  containerStyle,
194
217
  isCleanView,
218
+ isEdgeCompact,
195
219
  isPresentationMode,
196
220
  resolvedZeroLine,
197
221
  legendBackgroundColor,