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
package/src/demo/types.ts CHANGED
@@ -14,6 +14,7 @@ import type {
14
14
  WaveformZeroLineOptions,
15
15
  WaveformZoomEndPayload,
16
16
  } from '../components'
17
+ import type { DemoXAxisLabelFormat, DemoXAxisLabelTimeZone } from './useDemoXAxisLabelControls'
17
18
 
18
19
  interface SelectOption<T> {
19
20
  label: string
@@ -44,6 +45,14 @@ export interface DemoControlPanelModel {
44
45
  verticalGridColor: string
45
46
  xAxisLineVisible: boolean
46
47
  yAxisLineVisible: boolean
48
+ xAxisLabelFormatterEnabled: boolean
49
+ xAxisLabelFormat: DemoXAxisLabelFormat
50
+ xAxisLabelFormatOptions: Array<SelectOption<DemoXAxisLabelFormat>>
51
+ xAxisLabelMultiplier: number
52
+ xAxisLabelFractionDigits: number
53
+ xAxisLabelTimeZone: DemoXAxisLabelTimeZone
54
+ xAxisLabelTimeZoneOptions: Array<SelectOption<DemoXAxisLabelTimeZone>>
55
+ xAxisLabelShowMilliseconds: boolean
47
56
  frameBorderColor: string
48
57
  frameBackgroundColor: string
49
58
  frameBorderWidth: number
@@ -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
@@ -23,6 +23,9 @@ export type {
23
23
  WaveformLegendOrientation,
24
24
  WaveformLegendOptions,
25
25
  WaveformFrameStyle,
26
+ WaveformXAxisLabelKind,
27
+ WaveformXAxisLabelFormatterContext,
28
+ WaveformXAxisLabelFormatter,
26
29
  WaveformAxesOptions,
27
30
  WaveformZeroLineOptions,
28
31
  // 数据类型
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;
@@ -121,10 +121,31 @@ export interface WaveformFrameStyle {
121
121
  backgroundColor?: string
122
122
  }
123
123
 
124
+ export type WaveformXAxisLabelKind = 'tick' | 'start' | 'end'
125
+
126
+ /** Context passed to custom X-axis label formatters. */
127
+ export interface WaveformXAxisLabelFormatterContext {
128
+ kind: WaveformXAxisLabelKind
129
+ /** X coordinate in the source data, before time-unit conversion. */
130
+ rawValue: number
131
+ timeUnit: 's' | 'ms'
132
+ /** Current visible X domain in source coordinates. */
133
+ domain: [number, number]
134
+ /** Current visible X domain converted to the selected display unit. */
135
+ displayDomain: [number, number]
136
+ }
137
+
138
+ export type WaveformXAxisLabelFormatter = (
139
+ value: number,
140
+ context: WaveformXAxisLabelFormatterContext,
141
+ ) => string
142
+
124
143
  /** Controls axis baseline visibility while preserving tick marks and axis text. */
125
144
  export interface WaveformAxesOptions {
126
145
  x?: {
127
146
  lineVisible?: boolean
147
+ /** Formats display-unit X values for ticks and visible-range endpoints. */
148
+ labelFormatter?: WaveformXAxisLabelFormatter
128
149
  }
129
150
  y?: {
130
151
  lineVisible?: boolean
@@ -18,6 +18,9 @@ export type {
18
18
  WaveformLegendOrientation,
19
19
  WaveformLegendOptions,
20
20
  WaveformFrameStyle,
21
+ WaveformXAxisLabelKind,
22
+ WaveformXAxisLabelFormatterContext,
23
+ WaveformXAxisLabelFormatter,
21
24
  WaveformAxesOptions,
22
25
  WaveformZeroLineOptions,
23
26
  } from './chart'
@@ -1,71 +1,112 @@
1
1
  import { describe, expect, it } from 'vitest'
2
2
 
3
+ import type { WaveformXAxisLabelFormatter, WaveformXAxisLabelFormatterContext } from '../types'
3
4
  import {
4
5
  formatAnnotationTime,
5
6
  formatAxisTime,
6
- formatAxisTimeExponent,
7
7
  formatEndpointTime,
8
8
  formatPlainNumber,
9
9
  formatScientificAxisExponent,
10
10
  formatScientificAxisLabel,
11
11
  formatTooltipNumber,
12
+ formatTooltipTime,
13
+ formatXAxisLabel,
14
+ resolveScientificAxisExponent,
12
15
  shouldUseScientificAxisLabel,
13
16
  } from './formatters'
14
17
 
15
18
  describe('waveform number formatters', () => {
16
19
  it('uses the reference Y-axis scientific notation boundaries', () => {
17
20
  expect(shouldUseScientificAxisLabel(0)).toBe(false)
18
- expect(shouldUseScientificAxisLabel(0.009)).toBe(true)
19
- expect(shouldUseScientificAxisLabel(0.01)).toBe(false)
20
- expect(shouldUseScientificAxisLabel(99.99)).toBe(false)
21
- expect(shouldUseScientificAxisLabel(100)).toBe(true)
21
+ expect(shouldUseScientificAxisLabel(0.000999)).toBe(true)
22
+ expect(shouldUseScientificAxisLabel(0.001)).toBe(false)
23
+ expect(shouldUseScientificAxisLabel(999.999)).toBe(false)
24
+ expect(shouldUseScientificAxisLabel(1000)).toBe(true)
22
25
  })
23
26
 
24
- it('shares one separate exponent across an axis', () => {
25
- const positiveAxis = { axisMin: 0, axisMax: 254 }
26
- expect(formatScientificAxisLabel(127, positiveAxis)).toBe('1.27')
27
- expect(formatScientificAxisLabel(254, positiveAxis)).toBe('2.54')
28
- expect(formatScientificAxisExponent(0, 254)).toBe('E+02')
27
+ it('prefixes one shared exponent to the largest visible tick', () => {
28
+ const positiveAxis = { axisMin: 1000, axisMax: 3000 }
29
+ expect(formatScientificAxisLabel(1000, positiveAxis)).toBe('1')
30
+ expect(formatScientificAxisLabel(2000, positiveAxis)).toBe('2')
31
+ expect(formatScientificAxisLabel(3000, positiveAxis)).toBe('E+03 3')
32
+ expect(formatScientificAxisLabel(2000, { ...positiveAxis, topTickValue: 2000 })).toBe('E+03 2')
33
+ expect(formatScientificAxisExponent(1000, 3000)).toBe('E+03')
29
34
  expect(formatScientificAxisExponent(0, 1e120)).toBe('E+120')
30
35
 
31
- const tinyAxis = { axisMin: 0, axisMax: 0.0002 }
32
- expect(formatScientificAxisLabel(0.0001, tinyAxis)).toBe('1.00')
33
- expect(formatScientificAxisLabel(0.0002, tinyAxis)).toBe('2.00')
34
- expect(formatScientificAxisExponent(0, 0.0002)).toBe('E-04')
36
+ const tinyAxis = { axisMin: 0.0001, axisMax: 0.0003 }
37
+ expect(formatScientificAxisLabel(0.0001, tinyAxis)).toBe('1')
38
+ expect(formatScientificAxisLabel(0.0002, tinyAxis)).toBe('2')
39
+ expect(formatScientificAxisLabel(0.0003, tinyAxis)).toBe('E-04 3')
40
+ expect(formatScientificAxisExponent(0.0001, 0.0003)).toBe('E-04')
41
+ })
35
42
 
36
- const negativeAxis = { axisMin: -254, axisMax: 0 }
37
- expect(formatScientificAxisLabel(-254, negativeAxis)).toBe('-2.54')
38
- expect(formatScientificAxisLabel(0, negativeAxis)).toBe('0.00')
39
- expect(formatScientificAxisExponent(-254, 0)).toBe('E+02')
43
+ it('derives the exponent from Math.max(axisMin, axisMax)', () => {
44
+ expect(resolveScientificAxisExponent(-9000, -1000)).toBe(3)
45
+ expect(resolveScientificAxisExponent(-10_000, 3000)).toBe(3)
46
+ expect(resolveScientificAxisExponent(-1000, 0)).toBeNull()
47
+ expect(resolveScientificAxisExponent(0, 0)).toBeNull()
40
48
  })
41
49
 
42
- it('keeps plain axes at two decimals and removes negative zero', () => {
43
- expect(formatScientificAxisLabel(99.99, { axisMin: 0, axisMax: 99.99 })).toBe('99.99')
44
- expect(formatScientificAxisLabel(0.01, { axisMin: 0, axisMax: 0.01 })).toBe('0.01')
45
- expect(formatScientificAxisLabel(-0.001, { axisMin: -1, axisMax: 1 })).toBe('0.00')
50
+ it('keeps five significant digits, removes trailing zeroes, and limits decimals to four', () => {
51
+ expect(formatScientificAxisLabel(123.456, { axisMin: 0, axisMax: 999 })).toBe('123.46')
52
+ expect(formatScientificAxisLabel(1234.56, { axisMin: 0, axisMax: 9999 })).toBe('1.2346')
53
+ expect(formatScientificAxisLabel(3000, { axisMin: 1000, axisMax: 3000 })).toBe('E+03 3')
54
+ expect(formatScientificAxisLabel(-0, { axisMin: -1, axisMax: 1 })).toBe('0')
46
55
  expect(formatScientificAxisLabel(Number.NaN)).toBe('NaN')
47
56
  expect(formatScientificAxisLabel(Number.POSITIVE_INFINITY)).toBe('Infinity')
48
57
  expect(formatScientificAxisExponent(0, 0)).toBeNull()
49
58
  })
50
59
 
51
- it('formats X-axis ticks and endpoints from the selected display unit', () => {
60
+ it('formats X-axis ticks and endpoints as complete plain values in the display unit', () => {
52
61
  const domain: [number, number] = [0, 1]
53
- expect(formatAxisTime(0.5, 'ms', domain)).toBe('0.50')
54
- expect(formatEndpointTime(1, domain, 'ms')).toBe('1.00')
55
- expect(formatAxisTimeExponent(domain, 'ms')).toBe('E+03')
56
- expect(formatAxisTime(0.5, 's', domain)).toBe('0.50')
57
- expect(formatEndpointTime(1, domain, 's')).toBe('1.00')
58
- expect(formatAxisTimeExponent(domain, 's')).toBeNull()
62
+ expect(formatAxisTime(0.5004, 'ms', domain)).toBe('500.4')
63
+ expect(formatEndpointTime(1, domain, 'ms')).toBe('1000')
64
+ expect(formatAxisTime(0.5, 's', domain)).toBe('0.5')
65
+ expect(formatEndpointTime(1, domain, 's')).toBe('1')
59
66
 
60
67
  const tinyDomain: [number, number] = [0, 0.000001]
61
- expect(formatEndpointTime(0.000001, tinyDomain, 's')).toBe('1.00')
62
- expect(formatAxisTimeExponent(tinyDomain, 's')).toBe('E-06')
68
+ expect(formatEndpointTime(0.000001, tinyDomain, 's')).toBe('0.000001')
69
+ expect(formatAxisTime(-0.000001, 's', tinyDomain)).toBe('-0.000001')
70
+ expect(formatAxisTime(-0, 's')).toBe('0')
71
+ expect(formatAxisTime(1e21, 's')).toBe('1000000000000000000000')
72
+ expect(formatAxisTime(Number.POSITIVE_INFINITY, 's')).toBe('Infinity')
73
+ })
74
+
75
+ it('passes display values and complete source context to X-axis label formatters', () => {
76
+ const domain: [number, number] = [0.125, 1.875]
77
+ const formatter: WaveformXAxisLabelFormatter = (value, context) =>
78
+ `${context.kind}:${value / 10}`
79
+
80
+ expect(formatXAxisLabel(0.5, domain, 'ms', 'tick', formatter)).toBe('tick:50')
81
+ expect(formatXAxisLabel(domain[0], domain, 'ms', 'start', formatter)).toBe('start:12.5')
82
+ expect(formatXAxisLabel(domain[1], domain, 'ms', 'end', formatter)).toBe('end:187.5')
83
+
84
+ let receivedContext: WaveformXAxisLabelFormatterContext | undefined
85
+ formatXAxisLabel(0.5, domain, 'ms', 'tick', (value, context) => {
86
+ receivedContext = context
87
+ return String(value)
88
+ })
89
+ expect(receivedContext).toEqual({
90
+ kind: 'tick',
91
+ rawValue: 0.5,
92
+ timeUnit: 'ms',
93
+ domain: [0.125, 1.875],
94
+ displayDomain: [125, 1875],
95
+ })
96
+
97
+ expect(() =>
98
+ formatXAxisLabel(0.5, domain, 's', 'tick', () => {
99
+ throw new Error('formatter failed')
100
+ }),
101
+ ).toThrow('formatter failed')
63
102
  })
64
103
 
65
104
  it('formats tooltip and raw values for their display contexts', () => {
66
105
  expect(formatTooltipNumber(12345.67891)).toBe('12,345.6789')
67
106
  expect(formatTooltipNumber(-0)).toBe('0')
68
107
  expect(formatTooltipNumber(Number.POSITIVE_INFINITY)).toBe('Infinity')
108
+ expect(formatTooltipTime(1.234567, 's')).toBe('1.2346')
109
+ expect(formatTooltipTime(1, 'ms')).toBe('1,000')
69
110
  expect(formatPlainNumber(0.0000001)).toBe('0.0000001')
70
111
  expect(formatPlainNumber(1e21)).toBe('1000000000000000000000')
71
112
  expect(formatPlainNumber(-0)).toBe('0')
@@ -1,22 +1,26 @@
1
+ import type { WaveformXAxisLabelFormatter, WaveformXAxisLabelKind } from '../types'
2
+
1
3
  /**
2
4
  * 时间单位类型
3
5
  */
4
6
  export type TimeUnit = 'ms' | 's'
5
7
 
6
8
  export interface ScientificAxisLabelOptions {
9
+ /** @deprecated Y-axis labels always use five significant digits before localization. */
7
10
  precision?: number
8
11
  axisMin?: number
9
12
  axisMax?: number
13
+ topTickValue?: number
10
14
  }
11
15
 
12
16
  /** @deprecated Use ScientificAxisLabelOptions. */
13
- export type ScientificYAxisLabelOptions = ScientificAxisLabelOptions & {
14
- topTickValue?: number
15
- }
17
+ export type ScientificYAxisLabelOptions = ScientificAxisLabelOptions
16
18
 
17
- const DEFAULT_Y_AXIS_PRECISION = 2
18
- const SCIENTIFIC_MIN_ABSOLUTE_VALUE = 0.01
19
- const SCIENTIFIC_MAX_PLAIN_ABSOLUTE_VALUE = 100
19
+ const SCIENTIFIC_MIN_ABSOLUTE_VALUE = 0.001
20
+ const SCIENTIFIC_MAX_PLAIN_ABSOLUTE_VALUE = 1000
21
+ const Y_AXIS_NUMBER_FORMATTER = new Intl.NumberFormat('zh-CN', {
22
+ maximumFractionDigits: 4,
23
+ })
20
24
  const TOOLTIP_NUMBER_FORMATTER = new Intl.NumberFormat('zh-CN', {
21
25
  maximumFractionDigits: 4,
22
26
  })
@@ -38,9 +42,10 @@ export function shouldUseScientificAxisLabel(maxAbsoluteValue: number): boolean
38
42
  export function resolveScientificAxisExponent(axisMin?: number, axisMax?: number): number | null {
39
43
  if (typeof axisMin !== 'number' || typeof axisMax !== 'number') return null
40
44
 
41
- const maxAbsoluteValue = Math.max(Math.abs(axisMin), Math.abs(axisMax))
42
- return shouldUseScientificAxisLabel(maxAbsoluteValue)
43
- ? Math.floor(Math.log10(maxAbsoluteValue))
45
+ const maxValue = Math.max(axisMin, axisMax)
46
+ const absoluteMaxValue = Math.abs(maxValue)
47
+ return shouldUseScientificAxisLabel(absoluteMaxValue)
48
+ ? Math.floor(Math.log10(absoluteMaxValue))
44
49
  : null
45
50
  }
46
51
 
@@ -49,6 +54,13 @@ function formatExponent(exponent: number): string {
49
54
  return `E${sign}${Math.abs(exponent).toString().padStart(2, '0')}`
50
55
  }
51
56
 
57
+ function formatYAxisNumber(value: number): string {
58
+ if (Object.is(value, -0)) return '0'
59
+ const roundedValue = value === 0 ? 0 : Number(value.toPrecision(5))
60
+ const formatted = Y_AXIS_NUMBER_FORMATTER.format(roundedValue)
61
+ return formatted === '-0' ? '0' : formatted
62
+ }
63
+
52
64
  /** Format a Y-axis tick, sharing one exponent derived from the complete axis domain. */
53
65
  export function formatScientificAxisLabel(
54
66
  value: number,
@@ -56,10 +68,13 @@ export function formatScientificAxisLabel(
56
68
  ): string {
57
69
  if (!Number.isFinite(value)) return String(value)
58
70
 
59
- const precision = options.precision ?? DEFAULT_Y_AXIS_PRECISION
60
71
  const exponent = resolveScientificAxisExponent(options.axisMin, options.axisMax)
61
72
  const scaledValue = exponent === null ? value : value / 10 ** exponent
62
- return formatFixedNumber(scaledValue, precision)
73
+ const formattedValue = formatYAxisNumber(scaledValue)
74
+ const topTickValue = options.topTickValue ?? options.axisMax
75
+ return exponent !== null && value === topTickValue
76
+ ? `${formatExponent(exponent)} ${formattedValue}`
77
+ : formattedValue
63
78
  }
64
79
 
65
80
  /** Return the shared E-style multiplier for an axis, or null for a plain axis. */
@@ -119,6 +134,26 @@ export function displayTime(value: number, timeUnit: TimeUnit): number {
119
134
  return timeUnit === 'ms' ? value * 1000 : value
120
135
  }
121
136
 
137
+ /** Format an X-axis value without changing its source coordinate. */
138
+ export function formatXAxisLabel(
139
+ rawValue: number,
140
+ domain: [number, number],
141
+ timeUnit: TimeUnit,
142
+ kind: WaveformXAxisLabelKind,
143
+ formatter?: WaveformXAxisLabelFormatter,
144
+ ): string {
145
+ const value = displayTime(rawValue, timeUnit)
146
+ if (!formatter) return formatPlainNumber(value)
147
+
148
+ return formatter(value, {
149
+ kind,
150
+ rawValue,
151
+ timeUnit,
152
+ domain: [domain[0], domain[1]],
153
+ displayDomain: [displayTime(domain[0], timeUnit), displayTime(domain[1], timeUnit)],
154
+ })
155
+ }
156
+
122
157
  /**
123
158
  * 计算端点标签的小数位数
124
159
  * @param domain 数据域 [最小值, 最大值]
@@ -134,54 +169,33 @@ export function endpointFractionDigits(domain: [number, number], timeUnit: TimeU
134
169
  }
135
170
 
136
171
  /**
137
- * 按完整 X 轴显示域格式化端点时间
172
+ * 将 X 轴端点时间格式化为当前显示单位下的普通十进制文本
138
173
  * @param value 时间值(秒)
139
- * @param domain 数据域
174
+ * @param _domain 数据域(为保持现有调用签名而保留)
140
175
  * @param timeUnit 时间单位
141
176
  * @returns 格式化的时间字符串
142
177
  */
143
178
  export function formatEndpointTime(
144
179
  value: number,
145
- domain: [number, number],
180
+ _domain: [number, number],
146
181
  timeUnit: TimeUnit,
147
182
  ): string {
148
- const [axisMin, axisMax] = domain.map((domainValue) => displayTime(domainValue, timeUnit)) as [
149
- number,
150
- number,
151
- ]
152
- return formatScientificAxisLabel(displayTime(value, timeUnit), {
153
- axisMin,
154
- axisMax,
155
- })
183
+ return formatXAxisLabel(value, _domain, timeUnit, 'end')
156
184
  }
157
185
 
158
186
  /**
159
- * 按完整 X 轴显示域格式化时间刻度
187
+ * 将 X 轴时间刻度格式化为当前显示单位下的普通十进制文本
160
188
  * @param value 时间值(秒)
161
189
  * @param timeUnit 时间单位
190
+ * @param _domain 数据域(为保持现有调用签名而保留)
162
191
  * @returns 格式化的时间字符串
163
192
  */
164
193
  export function formatAxisTime(
165
194
  value: number,
166
195
  timeUnit: TimeUnit,
167
- domain?: [number, number],
196
+ _domain?: [number, number],
168
197
  ): string {
169
- const displayValue = displayTime(value, timeUnit)
170
- const displayDomain = domain?.map((domainValue) => displayTime(domainValue, timeUnit)) as
171
- [number, number] | undefined
172
- return formatScientificAxisLabel(displayValue, {
173
- axisMin: displayDomain?.[0],
174
- axisMax: displayDomain?.[1],
175
- })
176
- }
177
-
178
- /** Return the shared multiplier for an X-axis domain in its selected display unit. */
179
- export function formatAxisTimeExponent(
180
- domain: [number, number],
181
- timeUnit: TimeUnit,
182
- ): string | null {
183
- const [axisMin, axisMax] = domain.map((value) => displayTime(value, timeUnit)) as [number, number]
184
- return formatScientificAxisExponent(axisMin, axisMax)
198
+ return formatXAxisLabel(value, _domain ?? [value, value], timeUnit, 'tick')
185
199
  }
186
200
 
187
201
  /**
@@ -191,11 +205,7 @@ export function formatAxisTimeExponent(
191
205
  * @returns 格式化的时间字符串
192
206
  */
193
207
  export function formatTooltipTime(value: number, timeUnit: TimeUnit): string {
194
- const displayValue = displayTime(value, timeUnit)
195
- return displayValue.toLocaleString('zh-CN', {
196
- minimumFractionDigits: 4,
197
- maximumFractionDigits: 4,
198
- })
208
+ return formatTooltipNumber(displayTime(value, timeUnit))
199
209
  }
200
210
 
201
211
  /** Format an annotation X coordinate in the selected display time unit. */
@@ -11,7 +11,7 @@ export {
11
11
  endpointFractionDigits,
12
12
  formatEndpointTime,
13
13
  formatAxisTime,
14
- formatAxisTimeExponent,
14
+ formatXAxisLabel,
15
15
  formatTooltipTime,
16
16
  formatAnnotationTime,
17
17
  formatPlainNumber,