waveform-analysis 0.1.28 → 0.1.30

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 (48) hide show
  1. package/README.md +57 -33
  2. package/dist/index.cjs +1 -1
  3. package/dist/index.js +590 -577
  4. package/dist/types/components/WaveformChart.vue.d.ts +1 -0
  5. package/dist/types/components/core/constants.d.ts +2 -0
  6. package/dist/types/components/core/trackLayoutBuilder.d.ts +2 -1
  7. package/dist/types/components/core/useWaveformChartController.d.ts +6 -1
  8. package/dist/types/components/core/useWaveformLayout.d.ts +0 -1
  9. package/dist/types/components/core/useWaveformPresentation.d.ts +5 -0
  10. package/dist/types/components/core/waveformChartTypes.d.ts +3 -2
  11. package/dist/types/components/data/types.d.ts +1 -1
  12. package/dist/types/components/index.d.ts +1 -1
  13. package/dist/types/components/waveform.d.ts +1 -1
  14. package/dist/types/demo/types.d.ts +13 -1
  15. package/dist/types/demo/useDemoXAxisLabelControls.d.ts +32 -0
  16. package/dist/types/index.d.ts +1 -1
  17. package/dist/types/types/chart.d.ts +22 -0
  18. package/dist/types/types/index.d.ts +1 -1
  19. package/dist/types/utils/formatters.d.ts +5 -2
  20. package/dist/types/utils/index.d.ts +1 -1
  21. package/package.json +1 -1
  22. package/src/App.vue +17 -1
  23. package/src/components/WaveformChart.vue +1 -0
  24. package/src/components/WaveformChartView.vue +13 -10
  25. package/src/components/core/constants.ts +3 -0
  26. package/src/components/core/trackLayoutBuilder.ts +22 -4
  27. package/src/components/core/useWaveformLayout.ts +1 -3
  28. package/src/components/core/useWaveformPresentation.ts +17 -2
  29. package/src/components/core/waveformChartTypes.ts +3 -0
  30. package/src/components/data/types.ts +1 -0
  31. package/src/components/index.ts +1 -0
  32. package/src/components/rendering/WaveformTrackAxes.vue +6 -3
  33. package/src/components/waveform.ts +1 -0
  34. package/src/components/waveformChartCases/frameGridAndFormatting.test.ts +70 -3
  35. package/src/components/waveformChartCases/sizingAndTitles.test.ts +59 -0
  36. package/src/demo/DemoChartHost.vue +1 -0
  37. package/src/demo/DemoGridFrameControls.vue +99 -0
  38. package/src/demo/plotMarginControls.test.ts +29 -0
  39. package/src/demo/types.ts +13 -0
  40. package/src/demo/useDemoXAxisLabelControls.ts +77 -0
  41. package/src/demo/xAxisLabelControls.test.ts +86 -0
  42. package/src/index.ts +4 -0
  43. package/src/styles.css +9 -0
  44. package/src/types/chart.ts +29 -0
  45. package/src/types/index.ts +4 -0
  46. package/src/utils/formatters.test.ts +37 -5
  47. package/src/utils/formatters.ts +26 -16
  48. package/src/utils/index.ts +1 -0
@@ -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 {
@@ -66,10 +66,12 @@ function renderAxes() {
66
66
  axisBottom(props.track.xScale)
67
67
  .tickValues(props.track.xAxisTickValues)
68
68
  .tickFormat((value) =>
69
- formatAxisTime(
69
+ formatXAxisLabel(
70
70
  Number(value),
71
- props.timeUnit,
72
71
  props.track.xScale.domain() as [number, number],
72
+ props.timeUnit,
73
+ 'tick',
74
+ props.axes?.x?.labelFormatter,
73
75
  ),
74
76
  )
75
77
  .tickSize(-4)
@@ -93,6 +95,7 @@ watch(
93
95
  () => props.track.xAxisTickValues,
94
96
  () => props.track.yAxisTickValues,
95
97
  () => props.timeUnit,
98
+ () => props.axes?.x?.labelFormatter,
96
99
  () => props.axes?.x?.lineVisible,
97
100
  () => props.axes?.y?.lineVisible,
98
101
  ],
@@ -12,6 +12,7 @@ export type {
12
12
  WaveformAnnotationStyle,
13
13
  WaveformAnnotation,
14
14
  WaveformRenderingOptions,
15
+ WaveformPlotMargin,
15
16
  WaveformTitleTextStyle,
16
17
  WaveformTitleOptions,
17
18
  WaveformFrameStyle,
@@ -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,7 +163,7 @@ 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('4990')
166
+ expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4990.3')
166
167
  expect(wrapper.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
167
168
  })
168
169
 
@@ -267,6 +268,70 @@ describe('WaveformChart', () => {
267
268
  expect(endpointGroup.attributes('font-size')).toBe(xAxis.attributes('font-size'))
268
269
  })
269
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
+
270
335
  it('keeps zoom-change domains in source seconds', async () => {
271
336
  const wrapper = await mountSizedChart({
272
337
  kind: 'points',
@@ -307,6 +372,8 @@ describe('WaveformChart', () => {
307
372
  )
308
373
  expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe(initialStart)
309
374
  expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).not.toBe(initialEnd)
310
- expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toMatch(/^-?\d+$/)
375
+ expect(
376
+ Number.isFinite(Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())),
377
+ ).toBe(true)
311
378
  })
312
379
  })
@@ -105,6 +105,65 @@ describe('WaveformChart', () => {
105
105
  expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('300')
106
106
  })
107
107
 
108
+ it('configures plot top and bottom margins and reacts to updates', async () => {
109
+ const wrapper = await mountSizedChart(
110
+ { kind: 'samples', values: [0, 1], sampleRate: 1 },
111
+ {
112
+ displayMode: 'compact',
113
+ grid: { rowCount: 1, columnCount: 1 },
114
+ plotMargin: { top: 30, bottom: 70 },
115
+ },
116
+ )
117
+
118
+ expect(wrapper.attributes('data-plot-margin-top')).toBe('30')
119
+ expect(wrapper.attributes('data-plot-margin-bottom')).toBe('70')
120
+ expect(wrapper.get('.waveform-chart__overlay').attributes('height')).toBe('260')
121
+ expect(wrapper.get('.waveform-chart__svg > g').attributes('transform')).toContain(', 30)')
122
+
123
+ await wrapper.setProps({ plotMargin: { top: 12 } })
124
+
125
+ expect(wrapper.attributes('data-plot-margin-top')).toBe('12')
126
+ expect(wrapper.attributes('data-plot-margin-bottom')).toBe('52')
127
+ expect(wrapper.get('.waveform-chart__overlay').attributes('height')).toBe('296')
128
+ })
129
+
130
+ it('falls back to default plot margins for invalid values', async () => {
131
+ const wrapper = await mountSizedChart(
132
+ { kind: 'samples', values: [0, 1], sampleRate: 1 },
133
+ {
134
+ displayMode: 'compact',
135
+ grid: { rowCount: 1, columnCount: 1 },
136
+ plotMargin: { top: -1, bottom: Number.NaN },
137
+ },
138
+ )
139
+
140
+ expect(wrapper.attributes('data-plot-margin-top')).toBe('18')
141
+ expect(wrapper.attributes('data-plot-margin-bottom')).toBe('52')
142
+ expect(wrapper.get('.waveform-chart__overlay').attributes('height')).toBe('290')
143
+ })
144
+
145
+ it('keeps the chart title and X-axis title fixed when plot margins change', async () => {
146
+ const wrapper = await mountSizedChart(
147
+ { kind: 'samples', values: [0, 1], sampleRate: 1 },
148
+ {
149
+ displayMode: 'compact',
150
+ grid: { rowCount: 1, columnCount: 1 },
151
+ title: { text: '固定标题' },
152
+ },
153
+ )
154
+ const titleAreaStyle = wrapper.get('.waveform-chart__title-area').attributes('style')
155
+ const xAxisTitle = wrapper.get('.waveform-chart__x-label')
156
+ const initialX = xAxisTitle.attributes('x')
157
+ const initialY = xAxisTitle.attributes('y')
158
+
159
+ await wrapper.setProps({ plotMargin: { top: 60, bottom: 90 } })
160
+
161
+ expect(wrapper.get('.waveform-chart__title-area').attributes('style')).toBe(titleAreaStyle)
162
+ expect(wrapper.get('.waveform-chart__x-label').attributes('x')).toBe(initialX)
163
+ expect(wrapper.get('.waveform-chart__x-label').attributes('y')).toBe(initialY)
164
+ expect(wrapper.get('.waveform-chart__svg > g').attributes('transform')).toContain(', 60)')
165
+ })
166
+
108
167
  it('does not render or reserve space for missing, hidden, or blank titles', async () => {
109
168
  for (const title of [undefined, { visible: false, text: '隐藏标题' }, { text: ' ' }]) {
110
169
  const wrapper = await mountSizedChart(
@@ -45,6 +45,7 @@ defineExpose({ resetViewport })
45
45
  :clean-view="model.cleanView"
46
46
  :presentation-mode="model.presentationMode"
47
47
  :show-tooltip="model.showTooltip"
48
+ :plot-margin="model.plotMargin"
48
49
  :zero-line="model.zeroLine"
49
50
  :frame-number="model.frameWatermarkVisible ? 1 : undefined"
50
51
  :annotations-visible="model.annotationsVisible"
@@ -8,6 +8,35 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
8
8
  </script>
9
9
 
10
10
  <template>
11
+ <section class="control-section">
12
+ <h2>绘图区边距</h2>
13
+ <div class="plot-margin-controls">
14
+ <label class="frame-style-control">
15
+ <span>上边距</span>
16
+ <InputNumber
17
+ v-model:value="model.plotMarginTop"
18
+ :min="0"
19
+ :max="300"
20
+ :step="1"
21
+ addon-after="px"
22
+ size="small"
23
+ aria-label="绘图区上边距"
24
+ />
25
+ </label>
26
+ <label class="frame-style-control">
27
+ <span>下边距</span>
28
+ <InputNumber
29
+ v-model:value="model.plotMarginBottom"
30
+ :min="0"
31
+ :max="300"
32
+ :step="1"
33
+ addon-after="px"
34
+ size="small"
35
+ aria-label="绘图区下边距"
36
+ />
37
+ </label>
38
+ </div>
39
+ </section>
11
40
  <section class="control-section">
12
41
  <div class="control-section__header">
13
42
  <h2>零值参考线</h2>
@@ -96,6 +125,76 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
96
125
  <Switch v-model:checked="model.yAxisLineVisible" size="small" aria-label="显示纵轴线" />
97
126
  </div>
98
127
  </div>
128
+ <div class="x-axis-label-controls">
129
+ <label class="frame-style-control frame-style-control--switch">
130
+ <span>自定义 Label</span>
131
+ <Switch
132
+ v-model:checked="model.xAxisLabelFormatterEnabled"
133
+ size="small"
134
+ aria-label="自定义 X 轴 Label"
135
+ />
136
+ </label>
137
+ <label v-if="model.xAxisLabelFormatterEnabled" class="frame-style-control">
138
+ <span>格式类型</span>
139
+ <Select
140
+ v-model:value="model.xAxisLabelFormat"
141
+ :options="model.xAxisLabelFormatOptions"
142
+ size="small"
143
+ aria-label="X 轴 Label 格式类型"
144
+ />
145
+ </label>
146
+ <label
147
+ v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'number'"
148
+ class="frame-style-control"
149
+ >
150
+ <span>运算倍率</span>
151
+ <InputNumber
152
+ v-model:value="model.xAxisLabelMultiplier"
153
+ :min="-1000000"
154
+ :max="1000000"
155
+ :step="0.1"
156
+ size="small"
157
+ aria-label="X 轴 Label 运算倍率"
158
+ />
159
+ </label>
160
+ <label
161
+ v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'number'"
162
+ class="frame-style-control"
163
+ >
164
+ <span>小数位数</span>
165
+ <InputNumber
166
+ v-model:value="model.xAxisLabelFractionDigits"
167
+ :min="0"
168
+ :max="12"
169
+ :step="1"
170
+ size="small"
171
+ aria-label="X 轴 Label 小数位数"
172
+ />
173
+ </label>
174
+ <label
175
+ v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'datetime'"
176
+ class="frame-style-control"
177
+ >
178
+ <span>时区</span>
179
+ <Select
180
+ v-model:value="model.xAxisLabelTimeZone"
181
+ :options="model.xAxisLabelTimeZoneOptions"
182
+ size="small"
183
+ aria-label="X 轴 Label 时区"
184
+ />
185
+ </label>
186
+ <label
187
+ v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'datetime'"
188
+ class="frame-style-control frame-style-control--switch"
189
+ >
190
+ <span>显示毫秒</span>
191
+ <Switch
192
+ v-model:checked="model.xAxisLabelShowMilliseconds"
193
+ size="small"
194
+ aria-label="X 轴 Label 显示毫秒"
195
+ />
196
+ </label>
197
+ </div>
99
198
  </section>
100
199
  <section class="control-section">
101
200
  <h2>图框样式</h2>
@@ -0,0 +1,29 @@
1
+ import { flushPromises, mount } from '@vue/test-utils'
2
+ import { InputNumber } from 'ant-design-vue'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ import App from '../App.vue'
6
+ import { WaveformChart } from '../components'
7
+
8
+ describe('plot margin demo controls', () => {
9
+ it('updates the chart top and bottom margins from the sidebar', async () => {
10
+ const wrapper = mount(App)
11
+ await flushPromises()
12
+ const chart = wrapper.getComponent(WaveformChart)
13
+ const controls = wrapper.get('.plot-margin-controls').findAllComponents(InputNumber)
14
+
15
+ expect(controls).toHaveLength(2)
16
+ expect(controls[0]?.props('value')).toBe(18)
17
+ expect(controls[1]?.props('value')).toBe(52)
18
+ expect(chart.props('plotMargin')).toEqual({ top: 18, bottom: 52 })
19
+
20
+ controls[0]?.vm.$emit('update:value', 30)
21
+ controls[1]?.vm.$emit('update:value', 70)
22
+ await flushPromises()
23
+
24
+ expect(chart.props('plotMargin')).toEqual({ top: 30, bottom: 70 })
25
+ expect(wrapper.get('.waveform-chart').attributes('data-plot-margin-top')).toBe('30')
26
+ expect(wrapper.get('.waveform-chart').attributes('data-plot-margin-bottom')).toBe('70')
27
+ wrapper.unmount()
28
+ })
29
+ })
package/src/demo/types.ts CHANGED
@@ -10,10 +10,12 @@ import type {
10
10
  WaveformLegendPosition,
11
11
  WaveformLineStyle,
12
12
  WaveformOverlayMode,
13
+ WaveformPlotMargin,
13
14
  WaveformTitleOptions,
14
15
  WaveformZeroLineOptions,
15
16
  WaveformZoomEndPayload,
16
17
  } from '../components'
18
+ import type { DemoXAxisLabelFormat, DemoXAxisLabelTimeZone } from './useDemoXAxisLabelControls'
17
19
 
18
20
  interface SelectOption<T> {
19
21
  label: string
@@ -25,6 +27,8 @@ export interface DemoControlPanelModel {
25
27
  displayMode: WaveformDisplayMode
26
28
  overlayMode: WaveformOverlayMode
27
29
  showTooltip: boolean
30
+ plotMarginTop: number
31
+ plotMarginBottom: number
28
32
  cleanView: boolean
29
33
  presentationMode: boolean
30
34
  selectedSeriesId: string
@@ -44,6 +48,14 @@ export interface DemoControlPanelModel {
44
48
  verticalGridColor: string
45
49
  xAxisLineVisible: boolean
46
50
  yAxisLineVisible: boolean
51
+ xAxisLabelFormatterEnabled: boolean
52
+ xAxisLabelFormat: DemoXAxisLabelFormat
53
+ xAxisLabelFormatOptions: Array<SelectOption<DemoXAxisLabelFormat>>
54
+ xAxisLabelMultiplier: number
55
+ xAxisLabelFractionDigits: number
56
+ xAxisLabelTimeZone: DemoXAxisLabelTimeZone
57
+ xAxisLabelTimeZoneOptions: Array<SelectOption<DemoXAxisLabelTimeZone>>
58
+ xAxisLabelShowMilliseconds: boolean
47
59
  frameBorderColor: string
48
60
  frameBackgroundColor: string
49
61
  frameBorderWidth: number
@@ -90,6 +102,7 @@ export interface DemoChartModel {
90
102
  cleanView: boolean
91
103
  presentationMode: boolean
92
104
  showTooltip: boolean
105
+ plotMargin: WaveformPlotMargin
93
106
  zeroLine: WaveformZeroLineOptions
94
107
  frameWatermarkVisible: boolean
95
108
  annotations: WaveformAnnotation[]
@@ -0,0 +1,77 @@
1
+ import { computed, ref } from 'vue'
2
+
3
+ import type { WaveformXAxisLabelFormatter } from '../types'
4
+
5
+ export type DemoXAxisLabelFormat = 'number' | 'datetime'
6
+ export type DemoXAxisLabelTimeZone = 'local' | 'Asia/Shanghai' | 'UTC'
7
+
8
+ const formatOptions = [
9
+ { label: '数值', value: 'number' as const },
10
+ { label: '固定时间', value: 'datetime' as const },
11
+ ]
12
+ const timeZoneOptions = [
13
+ { label: '中国标准时间', value: 'Asia/Shanghai' as const },
14
+ { label: '本地时区', value: 'local' as const },
15
+ { label: 'UTC', value: 'UTC' as const },
16
+ ]
17
+
18
+ function pad(value: number, length = 2): string {
19
+ return String(value).padStart(length, '0')
20
+ }
21
+
22
+ export function formatDemoTimestamp(
23
+ value: number,
24
+ timeZone: DemoXAxisLabelTimeZone,
25
+ showMilliseconds: boolean,
26
+ ): string {
27
+ const offset = timeZone === 'Asia/Shanghai' ? 8 * 60 * 60 * 1000 : 0
28
+ const date = new Date(value + offset)
29
+ if (!Number.isFinite(date.getTime())) return String(value)
30
+
31
+ const useUtcFields = timeZone !== 'local'
32
+ const year = useUtcFields ? date.getUTCFullYear() : date.getFullYear()
33
+ const month = (useUtcFields ? date.getUTCMonth() : date.getMonth()) + 1
34
+ const day = useUtcFields ? date.getUTCDate() : date.getDate()
35
+ const hours = useUtcFields ? date.getUTCHours() : date.getHours()
36
+ const minutes = useUtcFields ? date.getUTCMinutes() : date.getMinutes()
37
+ const seconds = useUtcFields ? date.getUTCSeconds() : date.getSeconds()
38
+ const milliseconds = useUtcFields ? date.getUTCMilliseconds() : date.getMilliseconds()
39
+ const suffix = showMilliseconds ? `.${pad(milliseconds, 3)}` : ''
40
+ return `${year}-${pad(month)}-${pad(day)} ${pad(hours)}:${pad(minutes)}:${pad(seconds)}${suffix}`
41
+ }
42
+
43
+ export function useDemoXAxisLabelControls() {
44
+ const xAxisLabelFormatterEnabled = ref(false)
45
+ const xAxisLabelFormat = ref<DemoXAxisLabelFormat>('number')
46
+ const xAxisLabelMultiplier = ref(1)
47
+ const xAxisLabelFractionDigits = ref(3)
48
+ const xAxisLabelTimeZone = ref<DemoXAxisLabelTimeZone>('Asia/Shanghai')
49
+ const xAxisLabelShowMilliseconds = ref(false)
50
+ const xAxisLabelFormatter = computed<WaveformXAxisLabelFormatter | undefined>(() => {
51
+ if (!xAxisLabelFormatterEnabled.value) return undefined
52
+ if (xAxisLabelFormat.value === 'datetime') {
53
+ return (value) =>
54
+ formatDemoTimestamp(value, xAxisLabelTimeZone.value, xAxisLabelShowMilliseconds.value)
55
+ }
56
+
57
+ const multiplier = Number.isFinite(xAxisLabelMultiplier.value) ? xAxisLabelMultiplier.value : 1
58
+ const fractionDigits = Number.isFinite(xAxisLabelFractionDigits.value)
59
+ ? Math.min(12, Math.max(0, Math.trunc(xAxisLabelFractionDigits.value)))
60
+ : 0
61
+ return (value) => (value * multiplier).toFixed(fractionDigits)
62
+ })
63
+
64
+ return {
65
+ controlModel: {
66
+ xAxisLabelFormatterEnabled,
67
+ xAxisLabelFormat,
68
+ xAxisLabelFormatOptions: formatOptions,
69
+ xAxisLabelMultiplier,
70
+ xAxisLabelFractionDigits,
71
+ xAxisLabelTimeZone,
72
+ xAxisLabelTimeZoneOptions: timeZoneOptions,
73
+ xAxisLabelShowMilliseconds,
74
+ },
75
+ xAxisLabelFormatter,
76
+ }
77
+ }
@@ -0,0 +1,86 @@
1
+ import { flushPromises, mount } from '@vue/test-utils'
2
+ import { InputNumber, Select } from 'ant-design-vue'
3
+ import { describe, expect, it } from 'vitest'
4
+
5
+ import App from '../App.vue'
6
+ import { WaveformChart } from '../components'
7
+ import { formatDemoTimestamp } from './useDemoXAxisLabelControls'
8
+
9
+ describe('X-axis label demo controls', () => {
10
+ it('formats timestamps in fixed UTC and China-standard-time formats', () => {
11
+ expect(formatDemoTimestamp(0, 'UTC', false)).toBe('1970-01-01 00:00:00')
12
+ expect(formatDemoTimestamp(123, 'UTC', true)).toBe('1970-01-01 00:00:00.123')
13
+ expect(formatDemoTimestamp(0, 'Asia/Shanghai', false)).toBe('1970-01-01 08:00:00')
14
+ expect(formatDemoTimestamp(Number.NaN, 'UTC', false)).toBe('NaN')
15
+ })
16
+
17
+ it('configures numeric and timestamp labels from the sidebar', async () => {
18
+ const wrapper = mount(App)
19
+ await flushPromises()
20
+ const chart = wrapper.getComponent(WaveformChart)
21
+
22
+ expect(chart.props('axes')).toEqual({
23
+ x: { lineVisible: false },
24
+ y: { lineVisible: false },
25
+ })
26
+ expect(wrapper.find('[aria-label="自定义 X 轴 Label"]').exists()).toBe(true)
27
+ expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
28
+ expect(wrapper.find('[aria-label="X 轴 Label 小数位数"]').exists()).toBe(false)
29
+ expect(wrapper.find('[aria-label="X 轴 Label 格式类型"]').exists()).toBe(false)
30
+
31
+ await wrapper.get('[aria-label="自定义 X 轴 Label"]').trigger('click')
32
+ await flushPromises()
33
+ const inputNumbers = wrapper.findAllComponents(InputNumber)
34
+ const multiplierInput = inputNumbers.find((input) =>
35
+ input.find('[aria-label="X 轴 Label 运算倍率"]').exists(),
36
+ )
37
+ const fractionDigitsInput = inputNumbers.find((input) =>
38
+ input.find('[aria-label="X 轴 Label 小数位数"]').exists(),
39
+ )
40
+ expect(multiplierInput).toBeDefined()
41
+ expect(fractionDigitsInput).toBeDefined()
42
+ expect(multiplierInput?.props('value')).toBe(1)
43
+ expect(fractionDigitsInput?.props('value')).toBe(3)
44
+ expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toMatch(/\.000$/)
45
+
46
+ multiplierInput?.vm.$emit('update:value', 2)
47
+ fractionDigitsInput?.vm.$emit('update:value', 2)
48
+ await flushPromises()
49
+ const formatter = chart.props('axes')?.x?.labelFormatter
50
+ expect(formatter).toBeTypeOf('function')
51
+ expect(formatter?.(1.234, {} as never)).toBe('2.47')
52
+ expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toMatch(/\.00$/)
53
+
54
+ const formatSelect = wrapper
55
+ .findAllComponents(Select)
56
+ .find((select) => select.find('[aria-label="X 轴 Label 格式类型"]').exists())
57
+ expect(formatSelect?.props('value')).toBe('number')
58
+ formatSelect?.vm.$emit('update:value', 'datetime')
59
+ await flushPromises()
60
+ expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
61
+ expect(wrapper.find('[aria-label="X 轴 Label 小数位数"]').exists()).toBe(false)
62
+ expect(wrapper.find('[aria-label="X 轴 Label 时区"]').exists()).toBe(true)
63
+ expect(wrapper.find('[aria-label="X 轴 Label 显示毫秒"]').exists()).toBe(true)
64
+ expect(chart.props('axes')?.x?.labelFormatter?.(0, {} as never)).toBe('1970-01-01 08:00:00')
65
+
66
+ await wrapper.get('[aria-label="X 轴 Label 显示毫秒"]').trigger('click')
67
+ await flushPromises()
68
+ expect(chart.props('axes')?.x?.labelFormatter?.(123, {} as never)).toBe(
69
+ '1970-01-01 08:00:00.123',
70
+ )
71
+ const timeZoneSelect = wrapper
72
+ .findAllComponents(Select)
73
+ .find((select) => select.find('[aria-label="X 轴 Label 时区"]').exists())
74
+ timeZoneSelect?.vm.$emit('update:value', 'UTC')
75
+ await flushPromises()
76
+ expect(chart.props('axes')?.x?.labelFormatter?.(123, {} as never)).toBe(
77
+ '1970-01-01 00:00:00.123',
78
+ )
79
+
80
+ await wrapper.get('[aria-label="自定义 X 轴 Label"]').trigger('click')
81
+ await flushPromises()
82
+ expect(chart.props('axes')?.x?.labelFormatter).toBeUndefined()
83
+ expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
84
+ wrapper.unmount()
85
+ })
86
+ })
package/src/index.ts CHANGED
@@ -17,12 +17,16 @@ export type {
17
17
  WaveformAnnotationStyle,
18
18
  WaveformAnnotation,
19
19
  WaveformRenderingOptions,
20
+ WaveformPlotMargin,
20
21
  WaveformTitleTextStyle,
21
22
  WaveformTitleOptions,
22
23
  WaveformLegendPosition,
23
24
  WaveformLegendOrientation,
24
25
  WaveformLegendOptions,
25
26
  WaveformFrameStyle,
27
+ WaveformXAxisLabelKind,
28
+ WaveformXAxisLabelFormatterContext,
29
+ WaveformXAxisLabelFormatter,
26
30
  WaveformAxesOptions,
27
31
  WaveformZeroLineOptions,
28
32
  // 数据类型
package/src/styles.css CHANGED
@@ -133,6 +133,14 @@ body {
133
133
  grid-template-columns: minmax(0, 1fr) auto;
134
134
  }
135
135
 
136
+ .x-axis-label-controls {
137
+ display: grid;
138
+ gap: 10px;
139
+ margin-top: 12px;
140
+ padding-top: 12px;
141
+ border-top: 1px solid #eaecf0;
142
+ }
143
+
136
144
  .grid-line-color-picker,
137
145
  .grid-line-color-picker .vc-color-wrap {
138
146
  width: 48px;
@@ -187,6 +195,7 @@ body {
187
195
  }
188
196
 
189
197
  .frame-style-controls,
198
+ .plot-margin-controls,
190
199
  .auxiliary-style-controls {
191
200
  display: grid;
192
201
  gap: 10px;
@@ -74,6 +74,14 @@ export interface WaveformRenderingOptions {
74
74
  errorBarMinSpacing?: number
75
75
  }
76
76
 
77
+ /** Pixel margins reserved above and below the waveform plotting area. */
78
+ export interface WaveformPlotMargin {
79
+ /** Space between the top of the drawing SVG and the plotting area. Defaults to 18. */
80
+ top?: number
81
+ /** Space between the plotting area and the bottom of the drawing SVG. Defaults to 52. */
82
+ bottom?: number
83
+ }
84
+
77
85
  /** Text styling for the chart-level title. */
78
86
  export interface WaveformTitleTextStyle {
79
87
  color?: string
@@ -121,10 +129,31 @@ export interface WaveformFrameStyle {
121
129
  backgroundColor?: string
122
130
  }
123
131
 
132
+ export type WaveformXAxisLabelKind = 'tick' | 'start' | 'end'
133
+
134
+ /** Context passed to custom X-axis label formatters. */
135
+ export interface WaveformXAxisLabelFormatterContext {
136
+ kind: WaveformXAxisLabelKind
137
+ /** X coordinate in the source data, before time-unit conversion. */
138
+ rawValue: number
139
+ timeUnit: 's' | 'ms'
140
+ /** Current visible X domain in source coordinates. */
141
+ domain: [number, number]
142
+ /** Current visible X domain converted to the selected display unit. */
143
+ displayDomain: [number, number]
144
+ }
145
+
146
+ export type WaveformXAxisLabelFormatter = (
147
+ value: number,
148
+ context: WaveformXAxisLabelFormatterContext,
149
+ ) => string
150
+
124
151
  /** Controls axis baseline visibility while preserving tick marks and axis text. */
125
152
  export interface WaveformAxesOptions {
126
153
  x?: {
127
154
  lineVisible?: boolean
155
+ /** Formats display-unit X values for ticks and visible-range endpoints. */
156
+ labelFormatter?: WaveformXAxisLabelFormatter
128
157
  }
129
158
  y?: {
130
159
  lineVisible?: boolean
@@ -12,12 +12,16 @@ export type {
12
12
  WaveformAnnotationStyle,
13
13
  WaveformAnnotation,
14
14
  WaveformRenderingOptions,
15
+ WaveformPlotMargin,
15
16
  WaveformTitleTextStyle,
16
17
  WaveformTitleOptions,
17
18
  WaveformLegendPosition,
18
19
  WaveformLegendOrientation,
19
20
  WaveformLegendOptions,
20
21
  WaveformFrameStyle,
22
+ WaveformXAxisLabelKind,
23
+ WaveformXAxisLabelFormatterContext,
24
+ WaveformXAxisLabelFormatter,
21
25
  WaveformAxesOptions,
22
26
  WaveformZeroLineOptions,
23
27
  } from './chart'