waveform-analysis 0.1.39 → 0.1.41

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.
@@ -2,6 +2,7 @@ import type { WaveformOverlayMode } from '../../types';
2
2
  import type { DisplaySeries, DisplayTrack, TrackLayout } from './types';
3
3
  import { type WaveformYDomain } from './yDomain';
4
4
  export { MAX_MULTI_Y_AXIS_COUNT } from './constants';
5
+ export declare function resolveYAxisTickCount(_plotHeight: number, splitNumber?: number): number;
5
6
  export interface YAxisSeriesGroup {
6
7
  index: number;
7
8
  side: 'left' | 'right';
@@ -11,11 +12,11 @@ export interface YAxisSeriesGroup {
11
12
  }
12
13
  export declare function buildYAxisSeriesGroups(track: DisplayTrack, overlayMode: WaveformOverlayMode): YAxisSeriesGroup[];
13
14
  export declare function resolveYAxisSeriesGroups(track: DisplayTrack, overlayMode: WaveformOverlayMode, yDomain?: WaveformYDomain, yDomains?: Record<string, WaveformYDomain>): YAxisSeriesGroup[];
14
- export declare function axisTextMetrics(domain: [number, number], nice?: boolean, tickValues?: number[]): {
15
+ export declare function axisTextMetrics(domain: [number, number], nice?: boolean, tickValues?: number[], unit?: string, tickCount?: number): {
15
16
  tickTextWidth: number;
16
17
  };
17
- export declare function measureYAxisGroupClearance(group: YAxisSeriesGroup): number;
18
- export declare function measureTrackYAxisClearance(track: DisplayTrack, overlayMode: WaveformOverlayMode, yDomain?: WaveformYDomain, yDomains?: Record<string, WaveformYDomain>): {
18
+ export declare function measureYAxisGroupClearance(group: YAxisSeriesGroup, tickCount?: number): number;
19
+ export declare function measureTrackYAxisClearance(track: DisplayTrack, overlayMode: WaveformOverlayMode, yDomain?: WaveformYDomain, yDomains?: Record<string, WaveformYDomain>, tickCount?: number): {
19
20
  left: number;
20
21
  right: number;
21
22
  };
@@ -21,6 +21,7 @@ export interface BuildTrackLayoutsOptions {
21
21
  yDomains?: Record<string, [number, number]>;
22
22
  timeUnit: 's' | 'ms';
23
23
  xAxisLabelFormatter?: WaveformXAxisLabelFormatter;
24
+ yAxisSplitNumber?: number;
24
25
  rendering: ResolvedWaveformRenderingOptions;
25
26
  hideSecondaryLabels: boolean;
26
27
  yAxisLabelX: number;
@@ -150,6 +150,8 @@ export interface WaveformAxesOptions {
150
150
  };
151
151
  y?: {
152
152
  lineVisible?: boolean;
153
+ /** Target total number of equal Y-axis ticks, including endpoints. Defaults to 5. */
154
+ splitNumber?: number;
153
155
  };
154
156
  }
155
157
  /** Styling and visibility options for the horizontal zero-value reference line. */
@@ -9,6 +9,7 @@ export interface ScientificAxisLabelOptions {
9
9
  axisMin?: number;
10
10
  axisMax?: number;
11
11
  topTickValue?: number;
12
+ unit?: string;
12
13
  }
13
14
  /** @deprecated Use ScientificAxisLabelOptions. */
14
15
  export type ScientificYAxisLabelOptions = ScientificAxisLabelOptions;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "waveform-analysis",
3
- "version": "0.1.39",
3
+ "version": "0.1.41",
4
4
  "main": "./dist/index.cjs",
5
5
  "module": "./dist/index.js",
6
6
  "types": "./dist/types/index.d.ts",
@@ -35,7 +35,7 @@ function track(seriesList: DisplaySeries[]): DisplayTrack {
35
35
  }
36
36
 
37
37
  describe('fixed Y-domain layout', () => {
38
- it('uses an exact global fixed domain without applying nice bounds', () => {
38
+ it('expands a global fixed domain to nice equal intervals', () => {
39
39
  const sourceTrack = track([series('a', 0, 100)])
40
40
  const result = buildTrackLayouts({
41
41
  cells: [
@@ -66,10 +66,80 @@ describe('fixed Y-domain layout', () => {
66
66
  showCompactEmptyTracks: false,
67
67
  })[0]
68
68
 
69
- expect(result?.yScale.domain()).toEqual([3, 97])
70
- expect(result?.yAxes[0]?.tickValues).toContain(3)
71
- expect(result?.yAxes[0]?.tickValues).toContain(97)
72
- expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([3, 97])
69
+ expect(result?.yScale.domain()).toEqual([0, 100])
70
+ expect(result?.yAxes[0]?.tickValues).toContain(0)
71
+ expect(result?.yAxes[0]?.tickValues).toContain(100)
72
+ expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([0, 100])
73
+ })
74
+
75
+ it('uses the configured split number for fixed domains', () => {
76
+ const result = buildTrackLayouts({
77
+ cells: [
78
+ {
79
+ slotIndex: 0,
80
+ row: 0,
81
+ column: 0,
82
+ left: 0,
83
+ top: 0,
84
+ width: 120,
85
+ height: 100,
86
+ plotHeight: 100,
87
+ cellHeight: 130,
88
+ xAxisBand: 30,
89
+ series: track([series('a', 0, 100)]),
90
+ },
91
+ ],
92
+ grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
93
+ displayMode: 'independent',
94
+ overlayMode: 'single-axis',
95
+ independentTransforms: [zoomIdentity],
96
+ sharedZoomDomain: [0, 1],
97
+ fixedYDomain: [3, 97],
98
+ yAxisSplitNumber: 5,
99
+ timeUnit: 'ms',
100
+ rendering: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
101
+ hideSecondaryLabels: false,
102
+ yAxisLabelX: -50,
103
+ showCompactEmptyTracks: false,
104
+ })[0]
105
+
106
+ expect(result?.yAxes[0]?.majorTicks).toEqual([0, 25, 50, 75, 100])
107
+ })
108
+
109
+ it('defaults to five ticks and supports a two-tick axis', () => {
110
+ const build = (splitNumber?: number) =>
111
+ buildTrackLayouts({
112
+ cells: [
113
+ {
114
+ slotIndex: 0,
115
+ row: 0,
116
+ column: 0,
117
+ left: 0,
118
+ top: 0,
119
+ width: 120,
120
+ height: 100,
121
+ plotHeight: 100,
122
+ cellHeight: 130,
123
+ xAxisBand: 30,
124
+ series: track([series('a', 3, 97)]),
125
+ },
126
+ ],
127
+ grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
128
+ displayMode: 'independent',
129
+ overlayMode: 'single-axis',
130
+ independentTransforms: [zoomIdentity],
131
+ sharedZoomDomain: [0, 1],
132
+ fixedYDomain: [3, 97],
133
+ yAxisSplitNumber: splitNumber,
134
+ timeUnit: 'ms',
135
+ rendering: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
136
+ hideSecondaryLabels: false,
137
+ yAxisLabelX: -50,
138
+ showCompactEmptyTracks: false,
139
+ })[0]?.yAxes[0]?.majorTicks
140
+
141
+ expect(build()).toHaveLength(5)
142
+ expect(build(2)).toEqual([0, 100])
73
143
  })
74
144
 
75
145
  it('resolves track, series, and global fixed-domain precedence', () => {
@@ -9,6 +9,7 @@ import {
9
9
  findClosestTrackAtPointer,
10
10
  MAX_MULTI_Y_AXIS_COUNT,
11
11
  measureYAxisGroupClearance,
12
+ resolveYAxisTickCount,
12
13
  } from './layout'
13
14
 
14
15
  function series(id: string, minimum: number, maximum: number): DisplaySeries {
@@ -119,8 +120,15 @@ describe('multi-value Y-axis grouping', () => {
119
120
  showCompactEmptyTracks: false,
120
121
  })[0]
121
122
 
122
- expect(result?.yScale.domain()).toEqual([25, 75])
123
- expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([25, 75])
123
+ expect(result?.yScale.domain()).toEqual([20, 80])
124
+ expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([20, 80])
125
+ })
126
+
127
+ it('normalizes configured Y-axis split counts', () => {
128
+ expect(resolveYAxisTickCount(100, 0)).toBe(2)
129
+ expect(resolveYAxisTickCount(100, 4.8)).toBe(4)
130
+ expect(resolveYAxisTickCount(100, Number.NaN)).toBe(5)
131
+ expect(resolveYAxisTickCount(220)).toBe(5)
124
132
  })
125
133
 
126
134
  it('keeps every overlaid series on one axis in single-axis mode', () => {
@@ -20,6 +20,13 @@ const Y_AXIS_OUTER_PADDING = 4
20
20
  const Y_AXIS_LABEL_GAP = 6
21
21
  const Y_AXIS_LABEL_BAND_WIDTH = 24
22
22
 
23
+ export function resolveYAxisTickCount(_plotHeight: number, splitNumber?: number): number {
24
+ if (typeof splitNumber === 'number' && Number.isFinite(splitNumber)) {
25
+ return Math.max(2, Math.floor(splitNumber))
26
+ }
27
+ return 5
28
+ }
29
+
23
30
  export interface YAxisSeriesGroup {
24
31
  index: number
25
32
  side: 'left' | 'right'
@@ -103,16 +110,22 @@ export function axisTextMetrics(
103
110
  domain: [number, number],
104
111
  nice = true,
105
112
  tickValues?: number[],
113
+ unit?: string,
114
+ tickCount = 10,
106
115
  ): { tickTextWidth: number } {
116
+ const effectiveTickCount =
117
+ typeof tickCount === 'number' && Number.isFinite(tickCount)
118
+ ? Math.max(2, Math.floor(tickCount))
119
+ : 10
107
120
  const scale = scaleLinear(domain, [1, 0])
108
- if (nice) scale.nice()
121
+ if (nice) scale.nice(effectiveTickCount)
109
122
  const [axisMin, axisMax] = scale.domain()
110
- const values = tickValues ?? Array.from(new Set([axisMin, ...scale.ticks(10), axisMax]))
123
+ const values = tickValues ?? scale.ticks(effectiveTickCount)
111
124
  const topTickValue = Math.max(...values)
112
125
  const maximumTickCharacters = Math.max(
113
126
  1,
114
127
  ...values.map(
115
- (value) => formatScientificAxisLabel(value, { axisMin, axisMax, topTickValue }).length,
128
+ (value) => formatScientificAxisLabel(value, { axisMin, axisMax, topTickValue, unit }).length,
116
129
  ),
117
130
  )
118
131
  return {
@@ -120,9 +133,10 @@ export function axisTextMetrics(
120
133
  }
121
134
  }
122
135
 
123
- export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
136
+ export function measureYAxisGroupClearance(group: YAxisSeriesGroup, tickCount?: number): number {
124
137
  return (
125
- axisTextMetrics(group.domain, !group.fixed).tickTextWidth +
138
+ axisTextMetrics(group.domain, true, undefined, group.seriesList[0]?.unit, tickCount)
139
+ .tickTextWidth +
126
140
  Y_AXIS_TICK_PADDING +
127
141
  Y_AXIS_LABEL_GAP +
128
142
  Y_AXIS_LABEL_BAND_WIDTH +
@@ -130,9 +144,10 @@ export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
130
144
  )
131
145
  }
132
146
 
133
- function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number {
147
+ function measureYAxisGroupTickClearance(group: YAxisSeriesGroup, tickCount?: number): number {
134
148
  return (
135
- axisTextMetrics(group.domain, !group.fixed).tickTextWidth +
149
+ axisTextMetrics(group.domain, true, undefined, group.seriesList[0]?.unit, tickCount)
150
+ .tickTextWidth +
136
151
  Y_AXIS_TICK_PADDING +
137
152
  Y_AXIS_OUTER_PADDING
138
153
  )
@@ -143,13 +158,14 @@ export function measureTrackYAxisClearance(
143
158
  overlayMode: WaveformOverlayMode,
144
159
  yDomain?: WaveformYDomain,
145
160
  yDomains?: Record<string, WaveformYDomain>,
161
+ tickCount?: number,
146
162
  ): { left: number; right: number } {
147
163
  return resolveYAxisSeriesGroups(track, overlayMode, yDomain, yDomains).reduce(
148
164
  (clearance, group) => {
149
165
  clearance[group.side] +=
150
166
  overlayMode === 'multi-axis' || track.visibleSeries.length === 1
151
- ? measureYAxisGroupClearance(group)
152
- : measureYAxisGroupTickClearance(group)
167
+ ? measureYAxisGroupClearance(group, tickCount)
168
+ : measureYAxisGroupTickClearance(group, tickCount)
153
169
  return clearance
154
170
  },
155
171
  { left: 0, right: 0 },
@@ -25,7 +25,7 @@ import {
25
25
  type GridCellGeometry,
26
26
  type NormalizedWaveformGridOptions,
27
27
  } from './grid'
28
- import { axisTextMetrics, resolveYAxisSeriesGroups } from './layout'
28
+ import { axisTextMetrics, resolveYAxisSeriesGroups, resolveYAxisTickCount } from './layout'
29
29
  import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
30
30
  import { applyXDomainStrategy } from './xDomain'
31
31
  import {
@@ -54,6 +54,7 @@ export interface BuildTrackLayoutsOptions {
54
54
  yDomains?: Record<string, [number, number]>
55
55
  timeUnit: 's' | 'ms'
56
56
  xAxisLabelFormatter?: WaveformXAxisLabelFormatter
57
+ yAxisSplitNumber?: number
57
58
  rendering: ResolvedWaveformRenderingOptions
58
59
  hideSecondaryLabels: boolean
59
60
  yAxisLabelX: number
@@ -124,18 +125,27 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
124
125
  )
125
126
  const sideOffsets = { left: 0, right: 0 }
126
127
  const yAxes: WaveformYAxisLayout[] = yAxisGroups.map((group) => {
128
+ const tickCount = resolveYAxisTickCount(cell.plotHeight, options.yAxisSplitNumber)
129
+ const niceCount = Math.max(1, tickCount - 1)
127
130
  const scale = scaleLinear(group.domain, [cell.plotHeight, 0])
128
- if (!group.fixed) scale.nice()
129
- const majorTicks = scale.ticks(Math.max(2, Math.floor(cell.plotHeight / 55)))
131
+ scale.nice(niceCount)
130
132
  const [axisStart, axisEnd] = scale.domain()
133
+ const majorTicks = Array.from(
134
+ { length: tickCount },
135
+ (_, index) => axisStart + ((axisEnd - axisStart) * index) / (tickCount - 1),
136
+ )
131
137
  const showAxisEnd = options.displayMode !== 'compact' || cell.row === 0
132
138
  const visibleMajorTicks = showAxisEnd
133
139
  ? majorTicks
134
140
  : majorTicks.filter((tick) => tick !== axisEnd)
135
- const tickValues = Array.from(
136
- new Set([axisStart, ...visibleMajorTicks, ...(showAxisEnd ? [axisEnd] : [])]),
141
+ const tickValues = visibleMajorTicks
142
+ const { tickTextWidth } = axisTextMetrics(
143
+ scale.domain() as [number, number],
144
+ false,
145
+ tickValues,
146
+ group.seriesList[0]?.unit,
147
+ tickCount,
137
148
  )
138
- const { tickTextWidth } = axisTextMetrics(group.domain, !group.fixed, tickValues)
139
149
  const clearance =
140
150
  tickTextWidth +
141
151
  Y_AXIS_TICK_PADDING +
@@ -112,7 +112,16 @@ export function useWaveformLayout(context: LayoutContext) {
112
112
  .flatMap((track) =>
113
113
  resolveYAxisSeriesGroups(track, props.overlayMode, props.yDomain, props.yDomains),
114
114
  )
115
- .map((group) => axisTextMetrics(group.domain, !group.fixed).tickTextWidth)
115
+ .map(
116
+ (group) =>
117
+ axisTextMetrics(
118
+ group.domain,
119
+ true,
120
+ undefined,
121
+ group.seriesList[0]?.unit,
122
+ props.axes?.y?.splitNumber,
123
+ ).tickTextWidth,
124
+ )
116
125
  const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText)
117
126
  const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING
118
127
  const labelCenterX = -(
@@ -157,6 +166,7 @@ export function useWaveformLayout(context: LayoutContext) {
157
166
  props.overlayMode,
158
167
  props.yDomain,
159
168
  props.yDomains,
169
+ props.axes?.y?.splitNumber,
160
170
  )
161
171
  return {
162
172
  left: Math.max(maximum.left, clearance.left),
@@ -298,6 +308,7 @@ export function useWaveformLayout(context: LayoutContext) {
298
308
  : sharedYDomains.value,
299
309
  timeUnit: props.timeUnit,
300
310
  xAxisLabelFormatter: props.axes?.x?.labelFormatter,
311
+ yAxisSplitNumber: props.axes?.y?.splitNumber,
301
312
  rendering: renderingOptions.value,
302
313
  hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
303
314
  yAxisLabelX: yAxisMetrics.value.labelCenterX,
@@ -44,9 +44,10 @@ function renderAxes() {
44
44
  if (!element) return
45
45
  const [axisMin, axisMax] = axis.scale.domain()
46
46
  const topTickValue = Math.max(...axis.tickValues)
47
+ const unit = axis.seriesList[0]?.unit
47
48
  const yAxis = (axis.side === 'left' ? axisLeft(axis.scale) : axisRight(axis.scale))
48
49
  .tickFormat((value) =>
49
- formatScientificAxisLabel(Number(value), { axisMin, axisMax, topTickValue }),
50
+ formatScientificAxisLabel(Number(value), { axisMin, axisMax, topTickValue, unit }),
50
51
  )
51
52
  .tickSize(-4)
52
53
  .tickPadding(7)
@@ -65,9 +65,9 @@ describe('WaveformChart', () => {
65
65
  )
66
66
 
67
67
  expect(endTicks[0]).toHaveLength(1)
68
- expect(Number(endTicks[0][0].text())).toBe(0.9)
68
+ expect(Number(endTicks[0][0].text())).toBe(1)
69
69
  expect(endTicks[1]).toHaveLength(1)
70
- expect(Number(endTicks[1][0].text())).toBe(1.9)
70
+ expect(Number(endTicks[1][0].text())).toBe(2)
71
71
  expect(endTicks[2]).toHaveLength(0)
72
72
  expect(endTicks[3]).toHaveLength(0)
73
73
  })
@@ -158,6 +158,12 @@ describe('WaveformChart', () => {
158
158
  expect(paths[0].attributes('data-series-name')).toBe('BT2_2M')
159
159
  const yAxisLabels = wrapper.findAll('.waveform-chart__y-axis-label')
160
160
  expect(yAxisLabels.map((label) => label.text())).toEqual(['BT2_2M', 'BT1_2M'])
161
+ expect(
162
+ wrapper
163
+ .findAll('.waveform-chart__axis--y .tick text')
164
+ .map((label) => label.text())
165
+ .some((label) => label.includes('(T)')),
166
+ ).toBe(true)
161
167
  expect(yAxisLabels.map((label) => label.attributes('fill'))).toEqual(['#0960bd', '#ff7f0e'])
162
168
  const labelX = Number(
163
169
  yAxisLabels[0].attributes('transform')?.match(/^translate\(([-\d.]+),/)?.[1],
@@ -255,7 +261,7 @@ describe('WaveformChart', () => {
255
261
  })
256
262
 
257
263
  expect(startTicks).toHaveLength(1)
258
- expect(Number(startTicks[0].text())).toBe(0.1)
264
+ expect(Number(startTicks[0].text())).toBe(0)
259
265
  },
260
266
  )
261
267
 
@@ -291,7 +297,7 @@ describe('WaveformChart', () => {
291
297
  { displayMode },
292
298
  )
293
299
 
294
- const expectedEndValues = [0.9, 4.9]
300
+ const expectedEndValues = [1, 5]
295
301
  wrapper.findAll('.waveform-chart__track').forEach((track, index) => {
296
302
  const endTicks = track.findAll('.waveform-chart__axis--y .tick').filter((tick) => {
297
303
  const match = tick.attributes('transform')?.match(/translate\(0,\s*([\d.]+)\)/)
@@ -6,6 +6,26 @@ import { resizeObservers } from '../../test/setup'
6
6
  import { gridSeries, mountSizedChart } from '../../test/waveformChart'
7
7
 
8
8
  describe('WaveformChart', () => {
9
+ it('applies the configured Y-axis split number', async () => {
10
+ const wrapper = await mountSizedChart(
11
+ {
12
+ kind: 'points',
13
+ points: [
14
+ { x: 0, y: 3 },
15
+ { x: 1, y: 97 },
16
+ ],
17
+ },
18
+ { axes: { y: { splitNumber: 5 } } },
19
+ )
20
+
21
+ expect(
22
+ wrapper
23
+ .get('.waveform-chart__axis--y')
24
+ .findAll('.tick text')
25
+ .map((tick) => tick.text()),
26
+ ).toEqual(['0', '25', '50', '75', '100'])
27
+ })
28
+
9
29
  it('expands the Y-axis label gutter for signed values and long exponents', async () => {
10
30
  const wrapper = await mountSizedChart(
11
31
  {
@@ -296,7 +316,7 @@ describe('WaveformChart', () => {
296
316
  expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
297
317
  })
298
318
 
299
- it('reacts to exact fixed Y-domain props and returns to automatic bounds', async () => {
319
+ it('reacts to nice fixed Y-domain props and returns to automatic bounds', async () => {
300
320
  const wrapper = await mountSizedChart({
301
321
  kind: 'points',
302
322
  points: [
@@ -312,8 +332,8 @@ describe('WaveformChart', () => {
312
332
 
313
333
  await wrapper.setProps({ yDomain: [3, 97] })
314
334
  await flushPromises()
315
- expect(yTickLabels()).toContain('3')
316
- expect(yTickLabels()).toContain('97')
335
+ expect(yTickLabels()).toContain('0')
336
+ expect(yTickLabels()).toContain('100')
317
337
 
318
338
  await wrapper.setProps({ yDomain: undefined })
319
339
  await flushPromises()
@@ -15,6 +15,7 @@ describe('WaveformChart', () => {
15
15
  {
16
16
  trackId: 'shared-frame',
17
17
  name: '普通量程',
18
+ unit: 'V',
18
19
  data: {
19
20
  kind: 'points',
20
21
  points: [
@@ -26,6 +27,7 @@ describe('WaveformChart', () => {
26
27
  {
27
28
  trackId: 'shared-frame',
28
29
  name: '大量程',
30
+ unit: 'A',
29
31
  data: {
30
32
  kind: 'points',
31
33
  points: [
@@ -37,6 +39,7 @@ describe('WaveformChart', () => {
37
39
  {
38
40
  trackId: 'shared-frame',
39
41
  name: '小量程',
42
+ unit: 'T',
40
43
  data: {
41
44
  kind: 'points',
42
45
  points: [
@@ -57,7 +60,11 @@ describe('WaveformChart', () => {
57
60
  .map((label) => label.text())
58
61
  .filter((label) => label.startsWith('E')),
59
62
  ),
60
- ).toEqual([[], [expect.stringMatching(/^E\+03 /)], [expect.stringMatching(/^E-04 /)]])
63
+ ).toEqual([
64
+ [],
65
+ [expect.stringMatching(/^E\+03 \(A\) /)],
66
+ [expect.stringMatching(/^E-04 \(T\) /)],
67
+ ])
61
68
  })
62
69
 
63
70
  it('reprojects annotations with the Y axis assigned to their series', async () => {
@@ -174,6 +174,8 @@ export interface WaveformAxesOptions {
174
174
  }
175
175
  y?: {
176
176
  lineVisible?: boolean
177
+ /** Target total number of equal Y-axis ticks, including endpoints. Defaults to 5. */
178
+ splitNumber?: number
177
179
  }
178
180
  }
179
181
 
@@ -40,6 +40,15 @@ describe('waveform number formatters', () => {
40
40
  expect(formatScientificAxisExponent(0.0001, 0.0003)).toBe('E-04')
41
41
  })
42
42
 
43
+ it('appends a unit to the top tick with or without scientific notation', () => {
44
+ expect(formatScientificAxisLabel(3000, { axisMin: 1000, axisMax: 3000, unit: 'V' })).toBe(
45
+ 'E+03 (V) 3',
46
+ )
47
+ expect(formatScientificAxisLabel(3, { axisMin: 0, axisMax: 3, unit: 'A' })).toBe('(A) 3')
48
+ expect(formatScientificAxisLabel(2, { axisMin: 0, axisMax: 3, unit: 'A' })).toBe('2')
49
+ expect(formatScientificAxisLabel(3, { axisMin: 0, axisMax: 3, unit: ' ' })).toBe('3')
50
+ })
51
+
43
52
  it('derives the exponent from the largest absolute endpoint', () => {
44
53
  expect(resolveScientificAxisExponent(-9000, -1000)).toBe(3)
45
54
  expect(resolveScientificAxisExponent(-100_000, -3000)).toBe(5)
@@ -11,6 +11,7 @@ export interface ScientificAxisLabelOptions {
11
11
  axisMin?: number
12
12
  axisMax?: number
13
13
  topTickValue?: number
14
+ unit?: string
14
15
  }
15
16
 
16
17
  /** @deprecated Use ScientificAxisLabelOptions. */
@@ -71,9 +72,11 @@ export function formatScientificAxisLabel(
71
72
  const scaledValue = exponent === null ? value : value / 10 ** exponent
72
73
  const formattedValue = formatYAxisNumber(scaledValue)
73
74
  const topTickValue = options.topTickValue ?? options.axisMax
74
- return exponent !== null && value === topTickValue
75
- ? `${formatExponent(exponent)} ${formattedValue}`
76
- : formattedValue
75
+ if (value !== topTickValue) return formattedValue
76
+ const unit = options.unit?.trim()
77
+ const unitLabel = unit ? `(${unit}) ` : ''
78
+ const exponentLabel = exponent === null ? '' : `${formatExponent(exponent)} `
79
+ return `${exponentLabel}${unitLabel}${formattedValue}`
77
80
  }
78
81
 
79
82
  /** Return the shared E-style multiplier for an axis, or null for a plain axis. */