waveform-analysis 0.1.38 → 0.1.40

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.
@@ -1,6 +1,7 @@
1
1
  import type { WaveformChartProps } from './core/waveformChartTypes';
2
2
  declare const __VLS_export: import("vue").DefineComponent<WaveformChartProps, {
3
3
  resetViewport: (trackIndex?: number) => void;
4
+ setViewportDomain: (domain: [number, number], trackIndex?: number) => void;
4
5
  }, {}, {}, {}, import("vue").ComponentOptionsMixin, import("vue").ComponentOptionsMixin, {} & {
5
6
  "point-hover": (point: import("..").WaveformPoint | null) => any;
6
7
  "zoom-change": (domain: [number, number]) => any;
@@ -11,7 +11,7 @@ export interface YAxisSeriesGroup {
11
11
  }
12
12
  export declare function buildYAxisSeriesGroups(track: DisplayTrack, overlayMode: WaveformOverlayMode): YAxisSeriesGroup[];
13
13
  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[]): {
14
+ export declare function axisTextMetrics(domain: [number, number], nice?: boolean, tickValues?: number[], unit?: string): {
15
15
  tickTextWidth: number;
16
16
  };
17
17
  export declare function measureYAxisGroupClearance(group: YAxisSeriesGroup): number;
@@ -228,6 +228,7 @@ export declare function useWaveformChartController(props: ResolvedWaveformChartP
228
228
  finishViewportDrag: (event: PointerEvent) => void;
229
229
  cancelViewportDrag: (event?: PointerEvent) => void;
230
230
  resetViewport: (trackIndex?: number) => void;
231
+ setViewportDomain: (domain: [number, number], trackIndex?: number) => void;
231
232
  requestViewportReset: (event: MouseEvent) => void;
232
233
  toggleSeriesVisibility: (seriesId: string) => void;
233
234
  changeDraftSeries: (seriesId: string) => void;
@@ -31,6 +31,9 @@ interface LifecycleContext {
31
31
  trackLayouts: ComputedRef<TrackLayout[]>;
32
32
  innerWidth: ComputedRef<number>;
33
33
  innerHeight: ComputedRef<number>;
34
+ sharedZoomDomain: ComputedRef<[number, number]>;
35
+ initialXDomain: ComputedRef<[number, number]>;
36
+ sharedTransform: ShallowRef<ZoomTransform>;
34
37
  activeInteractionMode: ComputedRef<string | undefined>;
35
38
  hiddenSeriesIdSet: ComputedRef<Set<string>>;
36
39
  internalHiddenSeriesIds: Ref<Set<string>>;
@@ -42,6 +42,7 @@ export declare function useWaveformViewport(context: ViewportContext): {
42
42
  finishViewportDrag: (event: PointerEvent) => void;
43
43
  cancelViewportDrag: (event?: PointerEvent) => void;
44
44
  resetViewport: (trackIndex?: number) => void;
45
+ setViewportDomain: (domain: [number, number], trackIndex?: number) => void;
45
46
  requestViewportReset: (event: MouseEvent) => void;
46
47
  };
47
48
  export {};
@@ -0,0 +1,21 @@
1
+ import { type ComputedRef, type Ref, type ShallowRef } from 'vue';
2
+ import type { ZoomTransform } from 'd3';
3
+ import type { TrackLayout } from '../core/types';
4
+ import type { ResolvedWaveformChartProps } from '../core/waveformChartTypes';
5
+ import type { AnnotationSeriesCandidate } from '../annotation';
6
+ interface ViewportDomainContext {
7
+ props: ResolvedWaveformChartProps;
8
+ trackLayouts: ComputedRef<TrackLayout[]>;
9
+ initialXDomain: ComputedRef<[number, number]>;
10
+ resolveInitialTrackDomain: (track: TrackLayout) => [number, number];
11
+ innerWidth: ComputedRef<number>;
12
+ sharedTransform: ShallowRef<ZoomTransform>;
13
+ independentTransforms: ShallowRef<ZoomTransform[]>;
14
+ cancelPendingZoom: () => void;
15
+ cancelViewportDrag: () => void;
16
+ clearHover: () => void;
17
+ editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>;
18
+ configureZoom: () => void;
19
+ }
20
+ export declare function createViewportDomainSetter(context: ViewportDomainContext): (domain: [number, number], trackIndex?: number) => void;
21
+ export {};
@@ -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.38",
3
+ "version": "0.1.40",
4
4
  "main": "./dist/index.cjs",
5
5
  "module": "./dist/index.js",
6
6
  "types": "./dist/types/index.d.ts",
@@ -36,6 +36,7 @@ const controller = useWaveformChartController(props as ResolvedWaveformChartProp
36
36
 
37
37
  defineExpose({
38
38
  resetViewport: controller.resetViewport,
39
+ setViewportDomain: controller.setViewportDomain,
39
40
  })
40
41
  </script>
41
42
 
@@ -103,6 +103,7 @@ export function axisTextMetrics(
103
103
  domain: [number, number],
104
104
  nice = true,
105
105
  tickValues?: number[],
106
+ unit?: string,
106
107
  ): { tickTextWidth: number } {
107
108
  const scale = scaleLinear(domain, [1, 0])
108
109
  if (nice) scale.nice()
@@ -112,7 +113,7 @@ export function axisTextMetrics(
112
113
  const maximumTickCharacters = Math.max(
113
114
  1,
114
115
  ...values.map(
115
- (value) => formatScientificAxisLabel(value, { axisMin, axisMax, topTickValue }).length,
116
+ (value) => formatScientificAxisLabel(value, { axisMin, axisMax, topTickValue, unit }).length,
116
117
  ),
117
118
  )
118
119
  return {
@@ -122,7 +123,8 @@ export function axisTextMetrics(
122
123
 
123
124
  export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
124
125
  return (
125
- axisTextMetrics(group.domain, !group.fixed).tickTextWidth +
126
+ axisTextMetrics(group.domain, !group.fixed, undefined, group.seriesList[0]?.unit)
127
+ .tickTextWidth +
126
128
  Y_AXIS_TICK_PADDING +
127
129
  Y_AXIS_LABEL_GAP +
128
130
  Y_AXIS_LABEL_BAND_WIDTH +
@@ -132,7 +134,8 @@ export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
132
134
 
133
135
  function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number {
134
136
  return (
135
- axisTextMetrics(group.domain, !group.fixed).tickTextWidth +
137
+ axisTextMetrics(group.domain, !group.fixed, undefined, group.seriesList[0]?.unit)
138
+ .tickTextWidth +
136
139
  Y_AXIS_TICK_PADDING +
137
140
  Y_AXIS_OUTER_PADDING
138
141
  )
@@ -135,7 +135,12 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
135
135
  const tickValues = Array.from(
136
136
  new Set([axisStart, ...visibleMajorTicks, ...(showAxisEnd ? [axisEnd] : [])]),
137
137
  )
138
- const { tickTextWidth } = axisTextMetrics(group.domain, !group.fixed, tickValues)
138
+ const { tickTextWidth } = axisTextMetrics(
139
+ group.domain,
140
+ !group.fixed,
141
+ tickValues,
142
+ group.seriesList[0]?.unit,
143
+ )
139
144
  const clearance =
140
145
  tickTextWidth +
141
146
  Y_AXIS_TICK_PADDING +
@@ -259,6 +259,9 @@ export function useWaveformChartController(
259
259
  trackLayouts,
260
260
  innerWidth,
261
261
  innerHeight,
262
+ sharedZoomDomain,
263
+ initialXDomain,
264
+ sharedTransform,
262
265
  activeInteractionMode,
263
266
  hiddenSeriesIdSet,
264
267
  internalHiddenSeriesIds,
@@ -39,6 +39,9 @@ interface LifecycleContext {
39
39
  trackLayouts: ComputedRef<TrackLayout[]>
40
40
  innerWidth: ComputedRef<number>
41
41
  innerHeight: ComputedRef<number>
42
+ sharedZoomDomain: ComputedRef<[number, number]>
43
+ initialXDomain: ComputedRef<[number, number]>
44
+ sharedTransform: ShallowRef<ZoomTransform>
42
45
  activeInteractionMode: ComputedRef<string | undefined>
43
46
  hiddenSeriesIdSet: ComputedRef<Set<string>>
44
47
  internalHiddenSeriesIds: Ref<Set<string>>
@@ -93,6 +96,9 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
93
96
  trackLayouts,
94
97
  innerWidth,
95
98
  innerHeight,
99
+ sharedZoomDomain,
100
+ initialXDomain,
101
+ sharedTransform,
96
102
  activeInteractionMode,
97
103
  hiddenSeriesIdSet,
98
104
  internalHiddenSeriesIds,
@@ -144,25 +150,24 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
144
150
  emit('page-change', nextPage, pageCount.value)
145
151
  }
146
152
 
153
+ let pendingSharedXDomain: [number, number] | undefined
147
154
  let pendingIndependentXDomains: Array<[number, number] | undefined> | undefined
148
155
 
149
156
  function handleBeforeDataReferenceChange() {
150
- if (props.displayMode !== 'independent') return
151
- pendingIndependentXDomains = trackLayouts.value.map((track) => {
152
- const configuredDomain =
153
- props.initialXDomains?.[track.series.trackId ?? track.series.id] ??
154
- props.initialXDomains?.[track.series.id] ??
155
- props.initialXDomain
156
- if (
157
- !configuredDomain ||
158
- !Number.isFinite(configuredDomain[0]) ||
159
- !Number.isFinite(configuredDomain[1]) ||
160
- configuredDomain[0] === configuredDomain[1]
161
- ) {
162
- return undefined
163
- }
164
- return track.xScale.domain() as [number, number]
165
- })
157
+ if (props.displayMode === 'independent') {
158
+ pendingSharedXDomain = undefined
159
+ pendingIndependentXDomains = trackLayouts.value.map((track) => {
160
+ const current = track.xScale.domain() as [number, number]
161
+ const boundary = resolveInitialTrackDomain(track)
162
+ return current[1] - current[0] < boundary[1] - boundary[0] - 1e-12 ? current : undefined
163
+ })
164
+ return
165
+ }
166
+ pendingIndependentXDomains = undefined
167
+ const current = sharedZoomDomain.value
168
+ const boundary = initialXDomain.value
169
+ pendingSharedXDomain =
170
+ current[1] - current[0] < boundary[1] - boundary[0] - 1e-12 ? [...current] : undefined
166
171
  }
167
172
 
168
173
  function handleDataReferenceChange() {
@@ -186,10 +191,15 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
186
191
  })
187
192
  independentTransforms.value = nextTransforms
188
193
  pendingIndependentXDomains = undefined
189
- void nextTick(configureZoom)
190
- return
194
+ } else if (props.displayMode !== 'independent' && pendingSharedXDomain) {
195
+ const boundary = initialXDomain.value
196
+ const groups = trackLayouts.value
197
+ .filter((track) => track.hasVisibleSeries)
198
+ .map((track) => track.seriesList)
199
+ const domain = constrainZoomDomain(pendingSharedXDomain, boundary, groups, props)
200
+ sharedTransform.value = transformForDomain(domain, boundary, innerWidth.value)
201
+ pendingSharedXDomain = undefined
191
202
  }
192
- pendingIndependentXDomains = undefined
193
203
  configureZoom()
194
204
  })
195
205
  }
@@ -112,7 +112,11 @@ 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(group.domain, !group.fixed, undefined, group.seriesList[0]?.unit)
118
+ .tickTextWidth,
119
+ )
116
120
  const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText)
117
121
  const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING
118
122
  const labelCenterX = -(
@@ -11,6 +11,7 @@ import type {
11
11
  import type { AnnotationSeriesCandidate } from '../annotation'
12
12
  import { tryReleasePointerCapture } from './pointerCapture'
13
13
  import { transitionViewportInteraction } from './viewportInteractionState'
14
+ import { createViewportDomainSetter } from './viewportDomain'
14
15
  import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
15
16
  interface ViewportContext {
16
17
  props: ResolvedWaveformChartProps
@@ -344,6 +345,20 @@ export function useWaveformViewport(context: ViewportContext) {
344
345
  editorSeriesOptions.value = []
345
346
  void nextTick(configureZoom)
346
347
  }
348
+ const setViewportDomain = createViewportDomainSetter({
349
+ props,
350
+ trackLayouts,
351
+ initialXDomain,
352
+ resolveInitialTrackDomain,
353
+ innerWidth,
354
+ sharedTransform,
355
+ independentTransforms,
356
+ cancelPendingZoom,
357
+ cancelViewportDrag,
358
+ clearHover,
359
+ editorSeriesOptions,
360
+ configureZoom,
361
+ })
347
362
  const requestViewportReset = (event: MouseEvent) => {
348
363
  if (isPresentationMode.value || !props.zoomable || !isZoomMode.value) return
349
364
  if (props.displayMode === 'independent') {
@@ -372,6 +387,7 @@ export function useWaveformViewport(context: ViewportContext) {
372
387
  finishViewportDrag,
373
388
  cancelViewportDrag,
374
389
  resetViewport,
390
+ setViewportDomain,
375
391
  requestViewportReset,
376
392
  }
377
393
  }
@@ -0,0 +1,60 @@
1
+ import { nextTick, type ComputedRef, type Ref, type ShallowRef } from 'vue'
2
+ import type { ZoomTransform } from 'd3'
3
+
4
+ import type { TrackLayout } from '../core/types'
5
+ import type { ResolvedWaveformChartProps } from '../core/waveformChartTypes'
6
+ import type { AnnotationSeriesCandidate } from '../annotation'
7
+ import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
8
+
9
+ interface ViewportDomainContext {
10
+ props: ResolvedWaveformChartProps
11
+ trackLayouts: ComputedRef<TrackLayout[]>
12
+ initialXDomain: ComputedRef<[number, number]>
13
+ resolveInitialTrackDomain: (track: TrackLayout) => [number, number]
14
+ innerWidth: ComputedRef<number>
15
+ sharedTransform: ShallowRef<ZoomTransform>
16
+ independentTransforms: ShallowRef<ZoomTransform[]>
17
+ cancelPendingZoom: () => void
18
+ cancelViewportDrag: () => void
19
+ clearHover: () => void
20
+ editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
21
+ configureZoom: () => void
22
+ }
23
+
24
+ export function createViewportDomainSetter(context: ViewportDomainContext) {
25
+ return (domain: [number, number], trackIndex?: number) => {
26
+ if (!Number.isFinite(domain[0]) || !Number.isFinite(domain[1]) || domain[0] === domain[1])
27
+ return
28
+ context.cancelPendingZoom()
29
+ context.cancelViewportDrag()
30
+ if (context.props.displayMode === 'independent') {
31
+ const indexes =
32
+ trackIndex === undefined
33
+ ? context.trackLayouts.value.map((track) => track.index)
34
+ : [trackIndex]
35
+ const nextTransforms = [...context.independentTransforms.value]
36
+ indexes.forEach((index) => {
37
+ const track = context.trackLayouts.value.find((item) => item.index === index)
38
+ if (!track) return
39
+ const boundary = context.resolveInitialTrackDomain(track)
40
+ const constrained = constrainZoomDomain(domain, boundary, [track.seriesList], context.props)
41
+ nextTransforms[index] = transformForDomain(constrained, boundary, track.width)
42
+ })
43
+ context.independentTransforms.value = nextTransforms
44
+ } else {
45
+ const boundary = context.initialXDomain.value
46
+ const groups = context.trackLayouts.value
47
+ .filter((track) => track.hasVisibleSeries)
48
+ .map((track) => track.seriesList)
49
+ const constrained = constrainZoomDomain(domain, boundary, groups, context.props)
50
+ context.sharedTransform.value = transformForDomain(
51
+ constrained,
52
+ boundary,
53
+ context.innerWidth.value,
54
+ )
55
+ }
56
+ context.clearHover()
57
+ context.editorSeriesOptions.value = []
58
+ void nextTick(context.configureZoom)
59
+ }
60
+ }
@@ -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)
@@ -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],
@@ -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 () => {
@@ -119,6 +119,108 @@ describe('WaveformChart viewport reset', () => {
119
119
  expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1000')
120
120
  })
121
121
 
122
+ it('keeps a controlled shared viewport when the data reference changes', async () => {
123
+ const wrapper = await mountSizedChart(
124
+ {
125
+ kind: 'points',
126
+ points: [
127
+ { x: 0, y: 0 },
128
+ { x: 1, y: 1 },
129
+ ],
130
+ },
131
+ { displayMode: 'separated' },
132
+ )
133
+ const chart = wrapper.vm as unknown as {
134
+ setViewportDomain: (domain: [number, number], trackIndex?: number) => void
135
+ }
136
+ chart.setViewportDomain([0.25, 0.75])
137
+ await flushPromises()
138
+
139
+ const startBefore = Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())
140
+ const endBefore = Number(wrapper.get('.waveform-chart__axis-endpoint--end').text())
141
+ expect(startBefore).toBeGreaterThan(0)
142
+ expect(endBefore).toBeLessThan(1000)
143
+
144
+ await wrapper.setProps({
145
+ data: {
146
+ kind: 'points',
147
+ points: [
148
+ { x: 0, y: 10 },
149
+ { x: 1, y: 11 },
150
+ ],
151
+ },
152
+ })
153
+ await flushPromises()
154
+
155
+ expect(Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())).toBeCloseTo(
156
+ startBefore,
157
+ 10,
158
+ )
159
+ expect(Number(wrapper.get('.waveform-chart__axis-endpoint--end').text())).toBeCloseTo(
160
+ endBefore,
161
+ 10,
162
+ )
163
+ })
164
+
165
+ it('keeps independently controlled track viewports when the data reference changes', async () => {
166
+ const wrapper = await mountSizedChart(gridSeries(2), {
167
+ displayMode: 'independent',
168
+ grid: { rowCount: 1, columnCount: 2 },
169
+ })
170
+ const chart = wrapper.vm as unknown as {
171
+ setViewportDomain: (domain: [number, number], trackIndex?: number) => void
172
+ }
173
+ chart.setViewportDomain([0.2, 0.8], 0)
174
+ chart.setViewportDomain([0.1, 0.9], 1)
175
+ await flushPromises()
176
+
177
+ const endpoints = () =>
178
+ wrapper.findAll('.waveform-chart__track').map((track) => ({
179
+ start: track.get('.waveform-chart__axis-endpoint--start').text(),
180
+ end: track.get('.waveform-chart__axis-endpoint--end').text(),
181
+ }))
182
+ const endpointsBefore = endpoints()
183
+ expect(Number(endpointsBefore[0].start)).toBeGreaterThan(0)
184
+ expect(Number(endpointsBefore[0].end)).toBeLessThan(1000)
185
+ expect(Number(endpointsBefore[1].start)).toBeGreaterThan(0)
186
+ expect(Number(endpointsBefore[1].end)).toBeLessThan(1000)
187
+
188
+ await wrapper.setProps({ data: gridSeries(2) })
189
+ await flushPromises()
190
+
191
+ const endpointsAfter = endpoints()
192
+ endpointsAfter.forEach((endpoint, index) => {
193
+ expect(Number(endpoint.start)).toBeCloseTo(Number(endpointsBefore[index].start), 10)
194
+ expect(Number(endpoint.end)).toBeCloseTo(Number(endpointsBefore[index].end), 10)
195
+ })
196
+ })
197
+
198
+ it('ignores invalid domains and applies viewport constraints', async () => {
199
+ const wrapper = await mountSizedChart(
200
+ {
201
+ kind: 'points',
202
+ points: [
203
+ { x: 0, y: 0 },
204
+ { x: 1, y: 1 },
205
+ ],
206
+ },
207
+ { displayMode: 'separated', minZoomSpan: 0.2 },
208
+ )
209
+ const chart = wrapper.vm as unknown as {
210
+ setViewportDomain: (domain: [number, number], trackIndex?: number) => void
211
+ }
212
+ chart.setViewportDomain([Number.NaN, 0.5])
213
+ await flushPromises()
214
+ expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
215
+ expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
216
+
217
+ chart.setViewportDomain([0.49, 0.51])
218
+ await flushPromises()
219
+ const start = Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())
220
+ const end = Number(wrapper.get('.waveform-chart__axis-endpoint--end').text())
221
+ expect(end - start).toBeGreaterThanOrEqual(200)
222
+ })
223
+
122
224
  it('resets shared and independent explicit domains to their included nice bounds', async () => {
123
225
  const shared = await mountSizedChart(
124
226
  {
@@ -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. */