waveform-analysis 0.1.27 → 0.1.29

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 (43) hide show
  1. package/README.md +57 -33
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +451 -491
  4. package/dist/types/components/core/layout.d.ts +2 -4
  5. package/dist/types/components/core/trackLayoutBuilder.d.ts +2 -1
  6. package/dist/types/components/core/types.d.ts +0 -3
  7. package/dist/types/demo/types.d.ts +9 -0
  8. package/dist/types/demo/useDemoXAxisLabelControls.d.ts +32 -0
  9. package/dist/types/index.d.ts +1 -1
  10. package/dist/types/types/chart.d.ts +15 -0
  11. package/dist/types/types/index.d.ts +1 -1
  12. package/dist/types/utils/formatters.d.ts +12 -10
  13. package/dist/types/utils/index.d.ts +1 -1
  14. package/package.json +1 -1
  15. package/src/App.vue +7 -1
  16. package/src/components/core/layout.test.ts +7 -8
  17. package/src/components/core/layout.ts +10 -20
  18. package/src/components/core/trackLayoutBuilder.ts +24 -26
  19. package/src/components/core/types.ts +0 -3
  20. package/src/components/core/useWaveformLayout.ts +6 -27
  21. package/src/components/interaction/WaveformTooltip.test.ts +16 -0
  22. package/src/components/rendering/WaveformTrackAxes.vue +10 -29
  23. package/src/components/waveformChartCases/compactAndSharedHover.test.ts +7 -7
  24. package/src/components/waveformChartCases/dataAndHoverPerformance.test.ts +2 -2
  25. package/src/components/waveformChartCases/dataLifecycle.test.ts +4 -4
  26. package/src/components/waveformChartCases/frameGridAndFormatting.test.ts +97 -23
  27. package/src/components/waveformChartCases/interactionAndAnnotationCreation.test.ts +1 -1
  28. package/src/components/waveformChartCases/layoutAndDomains.test.ts +15 -15
  29. package/src/components/waveformChartCases/multiAxisAndPagination.test.ts +8 -8
  30. package/src/components/waveformChartCases/presentationAndAxes.test.ts +6 -4
  31. package/src/components/waveformChartCases/viewportConstraintsAndReset.test.ts +8 -8
  32. package/src/components/waveformChartCases/wheelZoomOut.test.ts +3 -3
  33. package/src/demo/DemoGridFrameControls.vue +70 -0
  34. package/src/demo/types.ts +9 -0
  35. package/src/demo/useDemoXAxisLabelControls.ts +77 -0
  36. package/src/demo/xAxisLabelControls.test.ts +86 -0
  37. package/src/index.ts +3 -0
  38. package/src/styles.css +8 -0
  39. package/src/types/chart.ts +21 -0
  40. package/src/types/index.ts +3 -0
  41. package/src/utils/formatters.test.ts +72 -31
  42. package/src/utils/formatters.ts +55 -45
  43. package/src/utils/index.ts +1 -1
@@ -3,7 +3,7 @@ import { axisBottom, axisLeft, axisRight, select } from 'd3'
3
3
  import { nextTick, onMounted, ref, watch } from 'vue'
4
4
 
5
5
  import type { WaveformAxesOptions } from '../../types'
6
- import { formatAxisTime, formatScientificAxisLabel } from '../../utils'
6
+ import { formatScientificAxisLabel, formatXAxisLabel } from '../../utils'
7
7
  import type { DisplaySeries, TrackLayout, WaveformYAxisLayout } from '../core/types'
8
8
 
9
9
  interface Props {
@@ -43,8 +43,11 @@ function renderAxes() {
43
43
  const element = yAxisElements.value[index]
44
44
  if (!element) return
45
45
  const [axisMin, axisMax] = axis.scale.domain()
46
+ const topTickValue = Math.max(...axis.tickValues)
46
47
  const yAxis = (axis.side === 'left' ? axisLeft(axis.scale) : axisRight(axis.scale))
47
- .tickFormat((value) => formatScientificAxisLabel(Number(value), { axisMin, axisMax }))
48
+ .tickFormat((value) =>
49
+ formatScientificAxisLabel(Number(value), { axisMin, axisMax, topTickValue }),
50
+ )
48
51
  .tickSize(-4)
49
52
  .tickPadding(7)
50
53
  .tickSizeOuter(0)
@@ -63,10 +66,12 @@ function renderAxes() {
63
66
  axisBottom(props.track.xScale)
64
67
  .tickValues(props.track.xAxisTickValues)
65
68
  .tickFormat((value) =>
66
- formatAxisTime(
69
+ formatXAxisLabel(
67
70
  Number(value),
68
- props.timeUnit,
69
71
  props.track.xScale.domain() as [number, number],
72
+ props.timeUnit,
73
+ 'tick',
74
+ props.axes?.x?.labelFormatter,
70
75
  ),
71
76
  )
72
77
  .tickSize(-4)
@@ -90,6 +95,7 @@ watch(
90
95
  () => props.track.xAxisTickValues,
91
96
  () => props.track.yAxisTickValues,
92
97
  () => props.timeUnit,
98
+ () => props.axes?.x?.labelFormatter,
93
99
  () => props.axes?.x?.lineVisible,
94
100
  () => props.axes?.y?.lineVisible,
95
101
  ],
@@ -136,17 +142,6 @@ watch(
136
142
  {{ track.endpointLabels.end }}
137
143
  </text>
138
144
  </g>
139
- <text
140
- v-if="track.showXAxis && track.xAxisExponent"
141
- class="waveform-track__axis-exponent waveform-track__axis-exponent--x waveform-chart__axis-exponent waveform-chart__axis-exponent--x"
142
- :x="track.width ?? innerWidth"
143
- :y="track.height + 27"
144
- text-anchor="end"
145
- aria-hidden="true"
146
- >
147
- {{ track.xAxisExponent }}
148
- </text>
149
-
150
145
  <g
151
146
  v-for="axis in track.isEmpty ? [] : track.yAxes"
152
147
  :key="`y-axis-${track.index}-${axis.index}`"
@@ -160,20 +155,6 @@ watch(
160
155
  :data-y-axis-side="axis.side"
161
156
  :transform="`translate(${axis.x}, 0)`"
162
157
  />
163
- <text
164
- v-for="axis in track.isEmpty ? [] : track.yAxes.filter((item) => item.exponentLabel)"
165
- :key="`y-axis-exponent-${track.index}-${axis.index}`"
166
- class="waveform-track__axis-exponent waveform-track__axis-exponent--y waveform-chart__axis-exponent waveform-chart__axis-exponent--y"
167
- :data-y-axis-index="axis.index"
168
- :x="axis.exponentX"
169
- y="0"
170
- dy="0.32em"
171
- :text-anchor="axis.side === 'left' ? 'end' : 'start'"
172
- aria-hidden="true"
173
- >
174
- {{ axis.exponentLabel }}
175
- </text>
176
-
177
158
  <g
178
159
  v-if="
179
160
  !cleanView &&
@@ -72,7 +72,7 @@ describe('WaveformChart', () => {
72
72
  expect(endTicks[3]).toHaveLength(0)
73
73
  })
74
74
 
75
- it('keeps one separate shared exponent for every compact Y axis', async () => {
75
+ it('prefixes one shared exponent to the largest visible tick on every compact Y axis', async () => {
76
76
  const wrapper = await mountSizedChart(
77
77
  {
78
78
  kind: 'series',
@@ -83,7 +83,7 @@ describe('WaveformChart', () => {
83
83
  kind: 'points',
84
84
  points: [
85
85
  { x: 0, y: 0 },
86
- { x: 1, y: 254 },
86
+ { x: 1, y: 2540 },
87
87
  ],
88
88
  },
89
89
  },
@@ -103,14 +103,14 @@ describe('WaveformChart', () => {
103
103
  )
104
104
 
105
105
  const axes = wrapper.findAll('.waveform-chart__axis--y')
106
- const exponents = wrapper.findAll('.waveform-chart__axis-exponent--y')
107
106
  expect(axes).toHaveLength(2)
108
- expect(exponents.map((label) => label.text())).toEqual(['E+02', 'E-04'])
109
- axes.forEach((axis) => {
107
+ axes.forEach((axis, index) => {
110
108
  const labels = axis.findAll('.tick text').map((tick) => tick.text())
111
- expect(labels.every((label) => !label.startsWith('E'))).toBe(true)
112
- expect(labels.every((label) => /^-?\d+\.\d{2}$/.test(label))).toBe(true)
109
+ const exponentLabels = labels.filter((label) => label.startsWith('E'))
110
+ expect(exponentLabels).toHaveLength(1)
111
+ expect(exponentLabels[0]).toMatch(index === 0 ? /^E\+03 / : /^E-04 /)
113
112
  })
113
+ expect(wrapper.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
114
114
  })
115
115
 
116
116
  it('prefers a trimmed series name and falls back to yLabel for unnamed data', async () => {
@@ -178,7 +178,7 @@ describe('WaveformChart', () => {
178
178
  await flushPromises()
179
179
  expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1, y: 5 }])
180
180
  expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
181
- expect(wrapper.get('.waveform-chart__tooltip').text()).toContain('ms: 1,000.0000')
181
+ expect(wrapper.get('.waveform-chart__tooltip-time').text()).toBe('ms: 1,000')
182
182
  const crosshairLines = wrapper.findAll('.waveform-chart__crosshair line')
183
183
  expect(crosshairLines).toHaveLength(1)
184
184
  expect(crosshairLines[0].attributes('x1')).toBe(crosshairLines[0].attributes('x2'))
@@ -303,7 +303,7 @@ describe('WaveformChart', () => {
303
303
  flushAnimationFrames()
304
304
  await flushPromises()
305
305
 
306
- expect(wrapper.get('.waveform-chart__tooltip').text()).toContain('ms: 1,000.0000')
306
+ expect(wrapper.get('.waveform-chart__tooltip-time').text()).toBe('ms: 1,000')
307
307
  expect(wrapper.findAll('.waveform-chart__crosshair line')).toHaveLength(1)
308
308
  expect(wrapper.get('.waveform-chart__line').element).toBe(pathBeforeHover)
309
309
  expect(chartUpdate).not.toHaveBeenCalled()
@@ -62,15 +62,15 @@ describe('WaveformChart', () => {
62
62
  ],
63
63
  }
64
64
  const wrapper = await mountSizedChart(firstData)
65
- expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
65
+ expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
66
66
 
67
67
  firstData.points.push({ x: 2, y: 2 })
68
68
  await flushPromises()
69
- expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
69
+ expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
70
70
 
71
71
  await wrapper.setProps({ data: { ...firstData, points: [...firstData.points] } })
72
72
  await flushPromises()
73
- expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
73
+ expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
74
74
  })
75
75
 
76
76
  it('keeps controlled annotations when replacing the loaded data window', async () => {
@@ -170,7 +170,7 @@ describe('WaveformChart', () => {
170
170
  expect(wrapper.findAll('.waveform-chart__track-label')).toHaveLength(0)
171
171
  expect(
172
172
  wrapper.findAll('.waveform-chart__axis-endpoint--end').map((item) => item.text()),
173
- ).toEqual(['1.00', '2.00'])
173
+ ).toEqual(['1000', '2000'])
174
174
  })
175
175
 
176
176
  it('keeps the zero Y-axis label on upper compact tracks', async () => {
@@ -1,7 +1,8 @@
1
1
  import { flushPromises } from '@vue/test-utils'
2
- import { describe, expect, it } from 'vitest'
2
+ import { describe, expect, it, vi } from 'vitest'
3
3
 
4
4
  import { flushAnimationFrames } from '../../test/setup'
5
+ import type { WaveformXAxisLabelFormatter } from '../../types'
5
6
  import { type WaveformData } from '../waveform'
6
7
 
7
8
  import { gridSeries, mountSizedChart } from '../../test/waveformChart'
@@ -162,35 +163,42 @@ describe('WaveformChart', () => {
162
163
  expect(wrapper.get('.waveform-chart__axis-endpoint--end').attributes('x')).toBe(
163
164
  String(trackWidth),
164
165
  )
165
- expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4.99')
166
- expect(wrapper.get('.waveform-chart__axis-exponent--x').text()).toBe('E+03')
166
+ expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4990.3')
167
+ expect(wrapper.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
167
168
  })
168
169
 
169
170
  it('uses one shared scientific exponent only for large and tiny Y-axis domains', async () => {
170
171
  const cases = [
171
- { values: [0, 50], exponent: null },
172
- { values: [0, 254], exponent: 'E+02' },
173
- { values: [0, 0.0002], exponent: 'E-04' },
172
+ { values: [0, 50], yDomain: [0, 50] as [number, number], exponent: null },
173
+ { values: [1000, 3000], yDomain: [1000, 3000] as [number, number], exponent: 'E+03' },
174
+ {
175
+ values: [0.0001, 0.0003],
176
+ yDomain: [0.0001, 0.0003] as [number, number],
177
+ exponent: 'E-04',
178
+ },
174
179
  ]
175
180
 
176
- for (const { values, exponent } of cases) {
177
- const wrapper = await mountSizedChart({
178
- kind: 'points',
181
+ for (const { values, yDomain, exponent } of cases) {
182
+ const data = {
183
+ kind: 'points' as const,
179
184
  points: values.map((y, x) => ({ x, y })),
180
- })
185
+ }
186
+ const originalValues = data.points.map((point) => point.y)
187
+ const wrapper = await mountSizedChart(data, { yDomain })
181
188
  const labels = wrapper
182
189
  .get('.waveform-chart__axis--y')
183
190
  .findAll('.tick text')
184
191
  .map((tick) => tick.text())
185
- const exponentLabel = wrapper.find('.waveform-chart__axis-exponent--y')
192
+ const exponentLabels = labels.filter((label) => label.startsWith('E'))
186
193
 
187
194
  if (exponent === null) {
188
- expect(exponentLabel.exists()).toBe(false)
195
+ expect(exponentLabels).toHaveLength(0)
189
196
  } else {
190
- expect(exponentLabel.text()).toBe(exponent)
191
- expect(labels.every((label) => !label.startsWith('E'))).toBe(true)
192
- expect(labels.every((label) => /^-?\d+\.\d{2}$/.test(label))).toBe(true)
197
+ expect(exponentLabels).toHaveLength(1)
198
+ expect(exponentLabels[0]).toMatch(new RegExp(`^${exponent.replace('+', '\\+')} `))
193
199
  }
200
+ expect(wrapper.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
201
+ expect(data.points.map((point) => point.y)).toEqual(originalValues)
194
202
 
195
203
  wrapper.unmount()
196
204
  }
@@ -212,13 +220,13 @@ describe('WaveformChart', () => {
212
220
 
213
221
  expect(millisecondsChart.text()).toContain('时间(ms)')
214
222
  expect(millisecondTicks.length).toBeGreaterThan(0)
215
- expect(millisecondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
216
- expect(millisecondsChart.get('.waveform-chart__axis-exponent--x').text()).toBe('E+03')
223
+ expect(millisecondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
224
+ expect(millisecondsChart.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
217
225
  expect(millisecondsChart.find('.waveform-chart__watermark').exists()).toBe(false)
218
226
 
219
227
  const secondsChart = await mountSizedChart(data, { timeUnit: 's', xLabel: 'Elapsed time' })
220
228
  expect(secondsChart.text()).toContain('Elapsed time')
221
- expect(secondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
229
+ expect(secondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1')
222
230
  expect(secondsChart.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
223
231
  })
224
232
 
@@ -237,15 +245,15 @@ describe('WaveformChart', () => {
237
245
 
238
246
  expect(start.attributes('x')).toBe('0')
239
247
  expect(start.attributes('text-anchor')).toBe('start')
240
- expect(start.text()).toBe('0.00')
248
+ expect(start.text()).toBe('0')
241
249
  expect(end.attributes('x')).toBe(
242
250
  wrapper.get('.waveform-chart__track').attributes('data-track-width'),
243
251
  )
244
252
  expect(end.attributes('text-anchor')).toBe('end')
245
- expect(end.text()).toBe('2.00')
253
+ expect(end.text()).toBe('1999')
246
254
  expect(middleTickLabels.length).toBeGreaterThan(0)
247
- expect(middleTickLabels).not.toContain('0.00')
248
- expect(wrapper.get('.waveform-chart__axis-exponent--x').text()).toBe('E+03')
255
+ expect(middleTickLabels).not.toContain('0')
256
+ expect(wrapper.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
249
257
  expect(wrapper.findAll('.waveform-chart__grid--major line').length).toBeGreaterThan(
250
258
  middleTickLabels.length,
251
259
  )
@@ -260,6 +268,70 @@ describe('WaveformChart', () => {
260
268
  expect(endpointGroup.attributes('font-size')).toBe(xAxis.attributes('font-size'))
261
269
  })
262
270
 
271
+ it('formats X-axis ticks and endpoints with display-unit values and source context', async () => {
272
+ const data: WaveformData = {
273
+ kind: 'points',
274
+ points: [
275
+ { x: 0.125, y: 0 },
276
+ { x: 1.875, y: 1 },
277
+ ],
278
+ }
279
+ const originalPoints = data.points.map((point) => ({ ...point }))
280
+ const labelFormatter: WaveformXAxisLabelFormatter = (value, context) =>
281
+ `${context.kind}:${value / 10}`
282
+ const formatter = vi.fn(labelFormatter)
283
+ const wrapper = await mountSizedChart(data, {
284
+ timeUnit: 'ms',
285
+ axes: { x: { labelFormatter: formatter } },
286
+ })
287
+
288
+ expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('start:12.5')
289
+ expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('end:187.5')
290
+ expect(
291
+ wrapper
292
+ .get('.waveform-chart__axis--x')
293
+ .findAll('.tick text')
294
+ .every((tick) => tick.text().startsWith('tick:')),
295
+ ).toBe(true)
296
+
297
+ const startCall = formatter.mock.calls.find(([, context]) => context.kind === 'start')
298
+ const tickCall = formatter.mock.calls.find(([, context]) => context.kind === 'tick')
299
+ const endCall = formatter.mock.calls.find(([, context]) => context.kind === 'end')
300
+ expect(startCall).toEqual([
301
+ 125,
302
+ {
303
+ kind: 'start',
304
+ rawValue: 0.125,
305
+ timeUnit: 'ms',
306
+ domain: [0.125, 1.875],
307
+ displayDomain: [125, 1875],
308
+ },
309
+ ])
310
+ expect(tickCall?.[0]).toBeTypeOf('number')
311
+ expect(tickCall?.[1]).toMatchObject({
312
+ kind: 'tick',
313
+ timeUnit: 'ms',
314
+ domain: [0.125, 1.875],
315
+ displayDomain: [125, 1875],
316
+ })
317
+ expect(endCall?.[1]).toMatchObject({ kind: 'end', rawValue: 1.875 })
318
+ expect(data.points).toEqual(originalPoints)
319
+
320
+ await wrapper.setProps({
321
+ axes: { x: { labelFormatter: (value: number) => `updated:${value}` } },
322
+ })
323
+ await flushPromises()
324
+ expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('updated:125')
325
+ expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('updated:1875')
326
+ expect(
327
+ wrapper
328
+ .get('.waveform-chart__axis--x')
329
+ .findAll('.tick text')
330
+ .every((tick) => tick.text().startsWith('updated:')),
331
+ ).toBe(true)
332
+ expect(data.points).toEqual(originalPoints)
333
+ })
334
+
263
335
  it('keeps zoom-change domains in source seconds', async () => {
264
336
  const wrapper = await mountSizedChart({
265
337
  kind: 'points',
@@ -300,6 +372,8 @@ describe('WaveformChart', () => {
300
372
  )
301
373
  expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe(initialStart)
302
374
  expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).not.toBe(initialEnd)
303
- expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toContain('.')
375
+ expect(
376
+ Number.isFinite(Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())),
377
+ ).toBe(true)
304
378
  })
305
379
  })
@@ -59,7 +59,7 @@ describe('WaveformChart', () => {
59
59
  await flushPromises()
60
60
 
61
61
  expect(wrapper.findAll('.waveform-chart__axis--x')).toHaveLength(1)
62
- expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
62
+ expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
63
63
  })
64
64
 
65
65
  it('updates rendering props and disables zoom interaction', async () => {
@@ -33,10 +33,10 @@ describe('WaveformChart', () => {
33
33
  tracks[0].get('.waveform-chart__y-axis-label-bg').attributes('x'),
34
34
  )
35
35
 
36
- expect(labelX).toBe(-103)
36
+ expect(labelX).toBe(-74)
37
37
  expect(labelBackgroundX).toBe(labelX - 12)
38
- expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(119)
39
- expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(119)
38
+ expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(90)
39
+ expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(90)
40
40
  })
41
41
 
42
42
  it('keeps a tick-only gutter when channel labels are empty', async () => {
@@ -62,8 +62,8 @@ describe('WaveformChart', () => {
62
62
  const secondLeft = Number(tracks[1].attributes('data-track-left'))
63
63
 
64
64
  expect(wrapper.findAll('.waveform-chart__y-axis-label')).toHaveLength(0)
65
- expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(89)
66
- expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(89)
65
+ expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(64)
66
+ expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(60)
67
67
  })
68
68
 
69
69
  it('keeps the Y-axis label gutter stable while paging between value ranges', async () => {
@@ -122,7 +122,7 @@ describe('WaveformChart', () => {
122
122
  const tracks = wrapper.findAll('.waveform-chart__track')
123
123
  const firstWidth = Number(tracks[0].attributes('data-track-width'))
124
124
  const secondLeft = Number(tracks[1].attributes('data-track-left'))
125
- expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(39)
125
+ expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(32)
126
126
  })
127
127
 
128
128
  it('uses one shared overlay and bottom-row x axes for separated and compact grids', async () => {
@@ -262,10 +262,10 @@ describe('WaveformChart', () => {
262
262
  )
263
263
 
264
264
  const tracks = wrapper.findAll('.waveform-chart__track')
265
- expect(tracks[0]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8.00')
266
- expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5.00')
267
- expect(tracks[1]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8.00')
268
- expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5.00')
265
+ expect(tracks[0]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8000')
266
+ expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000')
267
+ expect(tracks[1]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8000')
268
+ expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000')
269
269
  })
270
270
 
271
271
  it('uses an explicit initial x domain override for an independent track', async () => {
@@ -293,7 +293,7 @@ describe('WaveformChart', () => {
293
293
  },
294
294
  )
295
295
 
296
- expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
296
+ expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
297
297
  })
298
298
 
299
299
  it('reacts to exact fixed Y-domain props and returns to automatic bounds', async () => {
@@ -312,13 +312,13 @@ describe('WaveformChart', () => {
312
312
 
313
313
  await wrapper.setProps({ yDomain: [3, 97] })
314
314
  await flushPromises()
315
- expect(yTickLabels()).toContain('3.00')
316
- expect(yTickLabels()).toContain('97.00')
315
+ expect(yTickLabels()).toContain('3')
316
+ expect(yTickLabels()).toContain('97')
317
317
 
318
318
  await wrapper.setProps({ yDomain: undefined })
319
319
  await flushPromises()
320
- expect(yTickLabels()).not.toContain('3.00')
321
- expect(yTickLabels()).not.toContain('97.00')
320
+ expect(yTickLabels()).not.toContain('3')
321
+ expect(yTickLabels()).not.toContain('97')
322
322
  })
323
323
 
324
324
  it('keeps annotations bound to their channel while paging', async () => {
@@ -30,7 +30,7 @@ describe('WaveformChart', () => {
30
30
  kind: 'points',
31
31
  points: [
32
32
  { x: 0, y: 0 },
33
- { x: 1, y: 254 },
33
+ { x: 1, y: 2540 },
34
34
  ],
35
35
  },
36
36
  },
@@ -51,13 +51,13 @@ describe('WaveformChart', () => {
51
51
  )
52
52
 
53
53
  expect(
54
- wrapper
55
- .findAll('.waveform-chart__axis-exponent--y')
56
- .map((label) => [label.attributes('data-y-axis-index'), label.text()]),
57
- ).toEqual([
58
- ['1', 'E+02'],
59
- ['2', 'E-04'],
60
- ])
54
+ wrapper.findAll('.waveform-chart__axis--y').map((axis) =>
55
+ axis
56
+ .findAll('.tick text')
57
+ .map((label) => label.text())
58
+ .filter((label) => label.startsWith('E')),
59
+ ),
60
+ ).toEqual([[], [expect.stringMatching(/^E\+03 /)], [expect.stringMatching(/^E-04 /)]])
61
61
  })
62
62
 
63
63
  it('reprojects annotations with the Y axis assigned to their series', async () => {
@@ -54,8 +54,9 @@ describe('WaveformChart', () => {
54
54
  ],
55
55
  })
56
56
 
57
- expect(first.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
58
- expect(second.get('.waveform-chart__axis-exponent--y').text()).toBe('E+04')
57
+ expect(first.get('.waveform-chart__axis--y').text()).not.toContain('E')
58
+ expect(second.get('.waveform-chart__axis--y').text()).toContain('E+04 ')
59
+ expect(second.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
59
60
  })
60
61
 
61
62
  it('renders a configurable zero line only when the Y domain contains zero', async () => {
@@ -299,8 +300,9 @@ describe('WaveformChart', () => {
299
300
  })
300
301
  expect(xAxis.get('path.domain').attributes('display')).toBe('none')
301
302
  expect(wrapper.findAll('.waveform-chart__axis-endpoint')).toHaveLength(2)
302
- expect(wrapper.get('.waveform-chart__axis-exponent--x').text()).not.toBe('')
303
- expect(wrapper.get('.waveform-chart__axis-exponent--y').text()).not.toBe('')
303
+ expect(wrapper.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
304
+ expect(yAxes.some((axis) => axis.text().includes('E+04 '))).toBe(true)
305
+ expect(wrapper.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
304
306
  expect(wrapper.find('.waveform-chart__grid').exists()).toBe(false)
305
307
  expect(wrapper.get('.waveform-chart__plot-frame').attributes()).toMatchObject({
306
308
  stroke: '#dc2626',
@@ -79,8 +79,8 @@ describe('WaveformChart', () => {
79
79
 
80
80
  expect(wrapper.emitted('zoom-change')).toBeUndefined()
81
81
  expect(wrapper.emitted('zoom-end')).toBeUndefined()
82
- expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0.00')
83
- expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
82
+ expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
83
+ expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
84
84
  })
85
85
 
86
86
  it('ignores independent viewport dragging', async () => {
@@ -333,14 +333,14 @@ describe('WaveformChart', () => {
333
333
  )
334
334
  flushAnimationFrames()
335
335
  await flushPromises()
336
- expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe('0.00')
336
+ expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe('0')
337
337
 
338
338
  overlay.element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
339
339
  await flushPromises()
340
340
 
341
341
  expect(wrapper.emitted('zoom-reset')).toHaveLength(1)
342
- expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0.00')
343
- expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
342
+ expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
343
+ expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
344
344
  })
345
345
 
346
346
  it('exposes resetViewport for independent tracks', async () => {
@@ -364,13 +364,13 @@ describe('WaveformChart', () => {
364
364
  )
365
365
  flushAnimationFrames()
366
366
  await flushPromises()
367
- expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).not.toBe('0.00')
367
+ expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).not.toBe('0')
368
368
 
369
369
  const chart = wrapper.vm as unknown as { resetViewport: () => void }
370
370
  chart.resetViewport()
371
371
  await flushPromises()
372
372
 
373
- expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0.00')
374
- expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1.00')
373
+ expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0')
374
+ expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1000')
375
375
  })
376
376
  })
@@ -49,15 +49,15 @@ describe('WaveformChart wheel zoom out', () => {
49
49
  end: wrapper.get('.waveform-chart__axis-endpoint--end').text(),
50
50
  })
51
51
 
52
- expect(endpoints()).toEqual({ start: '-5.00', end: '5.00' })
52
+ expect(endpoints()).toEqual({ start: '-5000', end: '5000' })
53
53
  await dispatchWheel(-4000)
54
- expect(endpoints()).not.toEqual({ start: '-5.00', end: '5.00' })
54
+ expect(endpoints()).not.toEqual({ start: '-5000', end: '5000' })
55
55
 
56
56
  await wrapper.setProps({ data: createData(-0.125, 0.125) })
57
57
  await flushPromises()
58
58
  await dispatchWheel(4000)
59
59
 
60
- expect(endpoints()).toEqual({ start: '-5.00', end: '5.00' })
60
+ expect(endpoints()).toEqual({ start: '-5000', end: '5000' })
61
61
  })
62
62
 
63
63
  it('emits one zoom-end payload after zooming a shared viewport out', async () => {
@@ -96,6 +96,76 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
96
96
  <Switch v-model:checked="model.yAxisLineVisible" size="small" aria-label="显示纵轴线" />
97
97
  </div>
98
98
  </div>
99
+ <div class="x-axis-label-controls">
100
+ <label class="frame-style-control frame-style-control--switch">
101
+ <span>自定义 Label</span>
102
+ <Switch
103
+ v-model:checked="model.xAxisLabelFormatterEnabled"
104
+ size="small"
105
+ aria-label="自定义 X 轴 Label"
106
+ />
107
+ </label>
108
+ <label v-if="model.xAxisLabelFormatterEnabled" class="frame-style-control">
109
+ <span>格式类型</span>
110
+ <Select
111
+ v-model:value="model.xAxisLabelFormat"
112
+ :options="model.xAxisLabelFormatOptions"
113
+ size="small"
114
+ aria-label="X 轴 Label 格式类型"
115
+ />
116
+ </label>
117
+ <label
118
+ v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'number'"
119
+ class="frame-style-control"
120
+ >
121
+ <span>运算倍率</span>
122
+ <InputNumber
123
+ v-model:value="model.xAxisLabelMultiplier"
124
+ :min="-1000000"
125
+ :max="1000000"
126
+ :step="0.1"
127
+ size="small"
128
+ aria-label="X 轴 Label 运算倍率"
129
+ />
130
+ </label>
131
+ <label
132
+ v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'number'"
133
+ class="frame-style-control"
134
+ >
135
+ <span>小数位数</span>
136
+ <InputNumber
137
+ v-model:value="model.xAxisLabelFractionDigits"
138
+ :min="0"
139
+ :max="12"
140
+ :step="1"
141
+ size="small"
142
+ aria-label="X 轴 Label 小数位数"
143
+ />
144
+ </label>
145
+ <label
146
+ v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'datetime'"
147
+ class="frame-style-control"
148
+ >
149
+ <span>时区</span>
150
+ <Select
151
+ v-model:value="model.xAxisLabelTimeZone"
152
+ :options="model.xAxisLabelTimeZoneOptions"
153
+ size="small"
154
+ aria-label="X 轴 Label 时区"
155
+ />
156
+ </label>
157
+ <label
158
+ v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'datetime'"
159
+ class="frame-style-control frame-style-control--switch"
160
+ >
161
+ <span>显示毫秒</span>
162
+ <Switch
163
+ v-model:checked="model.xAxisLabelShowMilliseconds"
164
+ size="small"
165
+ aria-label="X 轴 Label 显示毫秒"
166
+ />
167
+ </label>
168
+ </div>
99
169
  </section>
100
170
  <section class="control-section">
101
171
  <h2>图框样式</h2>