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.
- package/README.md +57 -33
- package/dist/index.cjs +1 -1
- package/dist/index.js +451 -491
- package/dist/types/components/core/layout.d.ts +2 -4
- package/dist/types/components/core/trackLayoutBuilder.d.ts +2 -1
- package/dist/types/components/core/types.d.ts +0 -3
- package/dist/types/demo/types.d.ts +9 -0
- package/dist/types/demo/useDemoXAxisLabelControls.d.ts +32 -0
- package/dist/types/index.d.ts +1 -1
- package/dist/types/types/chart.d.ts +15 -0
- package/dist/types/types/index.d.ts +1 -1
- package/dist/types/utils/formatters.d.ts +12 -10
- package/dist/types/utils/index.d.ts +1 -1
- package/package.json +1 -1
- package/src/App.vue +7 -1
- package/src/components/core/layout.test.ts +7 -8
- package/src/components/core/layout.ts +10 -20
- package/src/components/core/trackLayoutBuilder.ts +24 -26
- package/src/components/core/types.ts +0 -3
- package/src/components/core/useWaveformLayout.ts +6 -27
- package/src/components/interaction/WaveformTooltip.test.ts +16 -0
- package/src/components/rendering/WaveformTrackAxes.vue +10 -29
- package/src/components/waveformChartCases/compactAndSharedHover.test.ts +7 -7
- package/src/components/waveformChartCases/dataAndHoverPerformance.test.ts +2 -2
- package/src/components/waveformChartCases/dataLifecycle.test.ts +4 -4
- package/src/components/waveformChartCases/frameGridAndFormatting.test.ts +97 -23
- package/src/components/waveformChartCases/interactionAndAnnotationCreation.test.ts +1 -1
- package/src/components/waveformChartCases/layoutAndDomains.test.ts +15 -15
- package/src/components/waveformChartCases/multiAxisAndPagination.test.ts +8 -8
- package/src/components/waveformChartCases/presentationAndAxes.test.ts +6 -4
- package/src/components/waveformChartCases/viewportConstraintsAndReset.test.ts +8 -8
- package/src/components/waveformChartCases/wheelZoomOut.test.ts +3 -3
- package/src/demo/DemoGridFrameControls.vue +70 -0
- package/src/demo/types.ts +9 -0
- package/src/demo/useDemoXAxisLabelControls.ts +77 -0
- package/src/demo/xAxisLabelControls.test.ts +86 -0
- package/src/index.ts +3 -0
- package/src/styles.css +8 -0
- package/src/types/chart.ts +21 -0
- package/src/types/index.ts +3 -0
- package/src/utils/formatters.test.ts +72 -31
- package/src/utils/formatters.ts +55 -45
- 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
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;
|
package/src/types/chart.ts
CHANGED
|
@@ -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
|
package/src/types/index.ts
CHANGED
|
@@ -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.
|
|
19
|
-
expect(shouldUseScientificAxisLabel(0.
|
|
20
|
-
expect(shouldUseScientificAxisLabel(
|
|
21
|
-
expect(shouldUseScientificAxisLabel(
|
|
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('
|
|
25
|
-
const positiveAxis = { axisMin:
|
|
26
|
-
expect(formatScientificAxisLabel(
|
|
27
|
-
expect(formatScientificAxisLabel(
|
|
28
|
-
expect(
|
|
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.
|
|
32
|
-
expect(formatScientificAxisLabel(0.0001, tinyAxis)).toBe('1
|
|
33
|
-
expect(formatScientificAxisLabel(0.0002, tinyAxis)).toBe('2
|
|
34
|
-
expect(
|
|
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
|
-
|
|
37
|
-
expect(
|
|
38
|
-
expect(
|
|
39
|
-
expect(
|
|
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
|
|
43
|
-
expect(formatScientificAxisLabel(
|
|
44
|
-
expect(formatScientificAxisLabel(
|
|
45
|
-
expect(formatScientificAxisLabel(
|
|
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
|
|
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.
|
|
54
|
-
expect(formatEndpointTime(1, domain, 'ms')).toBe('
|
|
55
|
-
expect(
|
|
56
|
-
expect(
|
|
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('
|
|
62
|
-
expect(
|
|
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')
|
package/src/utils/formatters.ts
CHANGED
|
@@ -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
|
|
18
|
-
const
|
|
19
|
-
const
|
|
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
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
172
|
+
* 将 X 轴端点时间格式化为当前显示单位下的普通十进制文本
|
|
138
173
|
* @param value 时间值(秒)
|
|
139
|
-
* @param
|
|
174
|
+
* @param _domain 数据域(为保持现有调用签名而保留)
|
|
140
175
|
* @param timeUnit 时间单位
|
|
141
176
|
* @returns 格式化的时间字符串
|
|
142
177
|
*/
|
|
143
178
|
export function formatEndpointTime(
|
|
144
179
|
value: number,
|
|
145
|
-
|
|
180
|
+
_domain: [number, number],
|
|
146
181
|
timeUnit: TimeUnit,
|
|
147
182
|
): string {
|
|
148
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
196
|
+
_domain?: [number, number],
|
|
168
197
|
): string {
|
|
169
|
-
|
|
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
|
-
|
|
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. */
|