feat(chart): support configurable x-axis labels
All checks were successful
Package component / package (push) Successful in 4m35s

This commit is contained in:
李启源
2026-08-05 10:58:40 +08:00
parent c5aa3c662a
commit fbf10fdb88
18 changed files with 505 additions and 66 deletions

View File

@@ -97,7 +97,7 @@ const data = ref<WaveformData>({
### Props
| Prop | 类型 | 默认值 | 说明 |
| --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | --------------------------------- |
| --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | ---------------------------------- |
| `data` | `WaveformData` | 必填 | 波形数据 |
| `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 |
| `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 |
@@ -114,7 +114,7 @@ const data = ref<WaveformData>({
| `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 |
| `yDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置固定范围 |
| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 |
| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐 |
| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐与 X 轴 label 格式化 |
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
| `frameNumber` | `string \| number` | 未设置 | 图框水印内容 |
@@ -129,8 +129,8 @@ const data = ref<WaveformData>({
所有公开类型均可从包入口导入,例如 `WaveformData``WaveformSeries`
`WaveformAnnotation``WaveformLineStyle``WaveformRenderingOptions`
`WaveformAxesOptions``WaveformZeroLineOptions``WaveformGridOptions`
`WaveformGridTrackLines`
`WaveformAxesOptions``WaveformXAxisLabelFormatter``WaveformZeroLineOptions`
`WaveformGridOptions``WaveformGridTrackLines`
### 数据结构
@@ -508,6 +508,30 @@ scale 定位零线:
/>
```
X 轴刻度和左右端点默认先按 `timeUnit` 转换为秒或毫秒,再显示为无千分位、无科学计数法的
完整普通十进制值,不会四舍五入为整数。可通过 `axes.x.labelFormatter` 对显示值做运算和格式化:
```vue
<WaveformChart
:data="chartData"
:axes="{
x: {
labelFormatter: (value, context) =>
`${context.kind === 'tick' ? '' : '[' + context.kind + '] '}${(value / 1000).toFixed(3)}`,
},
}"
/>
```
formatter 首参是已按 `timeUnit` 换算的数值;上下文包含 `kind``tick``start``end`)、
原始 `rawValue``timeUnit`、原始可视域 `domain` 和换算后的 `displayDomain`。formatter 只决定
label 文本,不改变刻度位置、源数据、缩放域或事件中的原始 X 坐标。
Demo 左侧“网格与轴线”支持直接切换数值或固定时间格式。数值模式可设置运算倍率和小数位数;
固定时间模式输出 `YYYY-MM-DD HH:mm:ss[.SSS]`,可选择中国标准时间、本地时区或 UTC并控制
是否显示毫秒。时间戳数据仍按组件的秒坐标契约传入,使用默认毫秒显示单位时 formatter 会收到
可直接传给 `Date` 的毫秒时间戳。
`interactionMode` 可选 `zoom``annotation`,默认使用缩放模式。右键绘图区可直接打开
标注编辑器,无需切换交互模式。`zoomable``pannable``showTooltip` 可分别控制缩放、
空格拖拽平移和 tooltip平移默认关闭。
@@ -603,7 +627,7 @@ async function importAnnotationFile(file: File) {
字段无效时会抛出 `TypeError`,不会返回部分结果。导入包含未知 `seriesId` 的标注是允许的,
对应曲线加载后会恢复显示。文件选择、错误提示和下载由业务层实现。
X 轴刻度和左右端点先按 `timeUnit` 转换为秒或毫秒,再四舍五入为不带分组符的普通整数,不使用科学计数法。Y 轴会根据完整显示域选择格式:最大绝对值在 `[0.01, 100)` 时显示两位普通小数;大于等于 `100`,或大于 `0` 且小于 `0.01` 时,刻度显示两位缩放值,并在轴末端单独显示共享倍率 `E±NN`,多 Y 轴分别计算倍率。tooltip 使用最多 4 位小数的本地化普通数字并省略无意义尾零;标注编辑器的 X 坐标跟随 `timeUnit` 并固定 3 位小数Y 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。
X 轴刻度和左右端点先按 `timeUnit` 转换为秒或毫秒,再显示为不带分组符和科学计数法的完整普通十进制值,并可通过 `axes.x.labelFormatter` 自定义。Y 轴会根据完整显示域选择格式:最大绝对值在 `[0.01, 100)` 时显示两位普通小数;大于等于 `100`,或大于 `0` 且小于 `0.01` 时,刻度显示两位缩放值,并在轴末端单独显示共享倍率 `E±NN`,多 Y 轴分别计算倍率。tooltip 使用最多 4 位小数的本地化普通数字并省略无意义尾零;标注编辑器的 X 坐标跟随 `timeUnit` 并固定 3 位小数Y 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。
标注框默认布局在采样点正上方,只做绘图区边界裁剪;文本框通过连接箭头指向标注位置,多个标注重叠时可通过拖动手动避让。
## 事件

View File

@@ -1,6 +1,6 @@
{
"name": "waveform-analysis",
"version": "0.1.28",
"version": "0.1.29",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/types/index.d.ts",

View File

@@ -23,6 +23,7 @@ import { createSimulatedWaveformData } from './data/simulatedWaveforms'
import DemoChartHost from './demo/DemoChartHost.vue'
import DemoControlPanel from './demo/DemoControlPanel.vue'
import type { DemoChartModel, DemoControlPanelModel } from './demo/types'
import { useDemoXAxisLabelControls } from './demo/useDemoXAxisLabelControls'
const fullChartData = createSimulatedWaveformData()
const displayMode = ref<WaveformDisplayMode>('independent')
@@ -40,6 +41,7 @@ const verticalGridVisible = ref(true)
const verticalGridColor = ref('#dfe5ef')
const xAxisLineVisible = ref(false)
const yAxisLineVisible = ref(false)
const { controlModel: xAxisLabelControlModel, xAxisLabelFormatter } = useDemoXAxisLabelControls()
const annotations = ref<WaveformAnnotation[]>([])
const annotationsVisible = ref(true)
const cleanView = ref(false)
@@ -112,7 +114,10 @@ const frameStyle = computed<WaveformFrameStyle>(() => ({
backgroundColor: frameBackgroundColor.value,
}))
const axes = computed<WaveformAxesOptions>(() => ({
x: { lineVisible: xAxisLineVisible.value },
x: {
lineVisible: xAxisLineVisible.value,
...(xAxisLabelFormatter.value ? { labelFormatter: xAxisLabelFormatter.value } : {}),
},
y: { lineVisible: yAxisLineVisible.value },
}))
const zeroLine = computed<WaveformZeroLineOptions>(() => ({
@@ -308,6 +313,7 @@ const controlPanelModel = reactive({
verticalGridColor,
xAxisLineVisible,
yAxisLineVisible,
...xAxisLabelControlModel,
frameBorderColor,
frameBackgroundColor,
frameBorderWidth,

View File

@@ -12,8 +12,13 @@ import {
selectSeriesRenderPoints,
type ResolvedWaveformRenderingOptions,
} from '../../core/rendering'
import type { WaveformDisplayMode, WaveformOverlayMode, WaveformPoint } from '../../types'
import { buildMinorTicks, formatEndpointTime } from '../../utils'
import type {
WaveformDisplayMode,
WaveformOverlayMode,
WaveformPoint,
WaveformXAxisLabelFormatter,
} from '../../types'
import { buildMinorTicks, formatXAxisLabel } from '../../utils'
import {
getBottomRowCellIndexes,
type GridCellGeometry,
@@ -45,6 +50,7 @@ export interface BuildTrackLayoutsOptions {
fixedYDomains?: Record<string, [number, number]>
yDomains?: Record<string, [number, number]>
timeUnit: 's' | 'ms'
xAxisLabelFormatter?: WaveformXAxisLabelFormatter
rendering: ResolvedWaveformRenderingOptions
hideSecondaryLabels: boolean
yAxisLabelX: number
@@ -147,8 +153,20 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
const yAxisTickValues = yAxes[0]?.tickValues ?? []
const domain = xScale.domain() as [number, number]
const endpointLabels = {
start: formatEndpointTime(domain[0], domain, options.timeUnit),
end: formatEndpointTime(domain[1], domain, options.timeUnit),
start: formatXAxisLabel(
domain[0],
domain,
options.timeUnit,
'start',
options.xAxisLabelFormatter,
),
end: formatXAxisLabel(
domain[1],
domain,
options.timeUnit,
'end',
options.xAxisLabelFormatter,
),
}
const leftClearance = endpointLabels.start.length * 7 + 10
const rightClearance = endpointLabels.end.length * 7 + 10

View File

@@ -285,6 +285,7 @@ export function useWaveformLayout(context: LayoutContext) {
)
: sharedYDomains.value,
timeUnit: props.timeUnit,
xAxisLabelFormatter: props.axes?.x?.labelFormatter,
rendering: renderingOptions.value,
hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
yAxisLabelX: yAxisMetrics.value.labelCenterX,

View File

@@ -3,7 +3,7 @@ import { axisBottom, axisLeft, axisRight, select } from 'd3'
import { nextTick, onMounted, ref, watch } from 'vue'
import type { WaveformAxesOptions } from '../../types'
import { formatAxisTime, formatScientificAxisLabel } from '../../utils'
import { formatScientificAxisLabel, formatXAxisLabel } from '../../utils'
import type { DisplaySeries, TrackLayout, WaveformYAxisLayout } from '../core/types'
interface Props {
@@ -66,10 +66,12 @@ function renderAxes() {
axisBottom(props.track.xScale)
.tickValues(props.track.xAxisTickValues)
.tickFormat((value) =>
formatAxisTime(
formatXAxisLabel(
Number(value),
props.timeUnit,
props.track.xScale.domain() as [number, number],
props.timeUnit,
'tick',
props.axes?.x?.labelFormatter,
),
)
.tickSize(-4)
@@ -93,6 +95,7 @@ watch(
() => props.track.xAxisTickValues,
() => props.track.yAxisTickValues,
() => props.timeUnit,
() => props.axes?.x?.labelFormatter,
() => props.axes?.x?.lineVisible,
() => props.axes?.y?.lineVisible,
],

View File

@@ -1,7 +1,8 @@
import { flushPromises } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { flushAnimationFrames } from '../../test/setup'
import type { WaveformXAxisLabelFormatter } from '../../types'
import { type WaveformData } from '../waveform'
import { gridSeries, mountSizedChart } from '../../test/waveformChart'
@@ -162,7 +163,7 @@ describe('WaveformChart', () => {
expect(wrapper.get('.waveform-chart__axis-endpoint--end').attributes('x')).toBe(
String(trackWidth),
)
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4990')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4990.3')
expect(wrapper.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
})
@@ -267,6 +268,70 @@ describe('WaveformChart', () => {
expect(endpointGroup.attributes('font-size')).toBe(xAxis.attributes('font-size'))
})
it('formats X-axis ticks and endpoints with display-unit values and source context', async () => {
const data: WaveformData = {
kind: 'points',
points: [
{ x: 0.125, y: 0 },
{ x: 1.875, y: 1 },
],
}
const originalPoints = data.points.map((point) => ({ ...point }))
const labelFormatter: WaveformXAxisLabelFormatter = (value, context) =>
`${context.kind}:${value / 10}`
const formatter = vi.fn(labelFormatter)
const wrapper = await mountSizedChart(data, {
timeUnit: 'ms',
axes: { x: { labelFormatter: formatter } },
})
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('start:12.5')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('end:187.5')
expect(
wrapper
.get('.waveform-chart__axis--x')
.findAll('.tick text')
.every((tick) => tick.text().startsWith('tick:')),
).toBe(true)
const startCall = formatter.mock.calls.find(([, context]) => context.kind === 'start')
const tickCall = formatter.mock.calls.find(([, context]) => context.kind === 'tick')
const endCall = formatter.mock.calls.find(([, context]) => context.kind === 'end')
expect(startCall).toEqual([
125,
{
kind: 'start',
rawValue: 0.125,
timeUnit: 'ms',
domain: [0.125, 1.875],
displayDomain: [125, 1875],
},
])
expect(tickCall?.[0]).toBeTypeOf('number')
expect(tickCall?.[1]).toMatchObject({
kind: 'tick',
timeUnit: 'ms',
domain: [0.125, 1.875],
displayDomain: [125, 1875],
})
expect(endCall?.[1]).toMatchObject({ kind: 'end', rawValue: 1.875 })
expect(data.points).toEqual(originalPoints)
await wrapper.setProps({
axes: { x: { labelFormatter: (value: number) => `updated:${value}` } },
})
await flushPromises()
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('updated:125')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('updated:1875')
expect(
wrapper
.get('.waveform-chart__axis--x')
.findAll('.tick text')
.every((tick) => tick.text().startsWith('updated:')),
).toBe(true)
expect(data.points).toEqual(originalPoints)
})
it('keeps zoom-change domains in source seconds', async () => {
const wrapper = await mountSizedChart({
kind: 'points',
@@ -307,6 +372,8 @@ describe('WaveformChart', () => {
)
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe(initialStart)
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).not.toBe(initialEnd)
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toMatch(/^-?\d+$/)
expect(
Number.isFinite(Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())),
).toBe(true)
})
})

View File

@@ -96,6 +96,76 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
<Switch v-model:checked="model.yAxisLineVisible" size="small" aria-label="显示纵轴线" />
</div>
</div>
<div class="x-axis-label-controls">
<label class="frame-style-control frame-style-control--switch">
<span>自定义 Label</span>
<Switch
v-model:checked="model.xAxisLabelFormatterEnabled"
size="small"
aria-label="自定义 X Label"
/>
</label>
<label v-if="model.xAxisLabelFormatterEnabled" class="frame-style-control">
<span>格式类型</span>
<Select
v-model:value="model.xAxisLabelFormat"
:options="model.xAxisLabelFormatOptions"
size="small"
aria-label="X Label 格式类型"
/>
</label>
<label
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'number'"
class="frame-style-control"
>
<span>运算倍率</span>
<InputNumber
v-model:value="model.xAxisLabelMultiplier"
:min="-1000000"
:max="1000000"
:step="0.1"
size="small"
aria-label="X Label 运算倍率"
/>
</label>
<label
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'number'"
class="frame-style-control"
>
<span>小数位数</span>
<InputNumber
v-model:value="model.xAxisLabelFractionDigits"
:min="0"
:max="12"
:step="1"
size="small"
aria-label="X Label 小数位数"
/>
</label>
<label
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'datetime'"
class="frame-style-control"
>
<span>时区</span>
<Select
v-model:value="model.xAxisLabelTimeZone"
:options="model.xAxisLabelTimeZoneOptions"
size="small"
aria-label="X Label 时区"
/>
</label>
<label
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'datetime'"
class="frame-style-control frame-style-control--switch"
>
<span>显示毫秒</span>
<Switch
v-model:checked="model.xAxisLabelShowMilliseconds"
size="small"
aria-label="X Label 显示毫秒"
/>
</label>
</div>
</section>
<section class="control-section">
<h2>图框样式</h2>

View File

@@ -14,6 +14,7 @@ import type {
WaveformZeroLineOptions,
WaveformZoomEndPayload,
} from '../components'
import type { DemoXAxisLabelFormat, DemoXAxisLabelTimeZone } from './useDemoXAxisLabelControls'
interface SelectOption<T> {
label: string
@@ -44,6 +45,14 @@ export interface DemoControlPanelModel {
verticalGridColor: string
xAxisLineVisible: boolean
yAxisLineVisible: boolean
xAxisLabelFormatterEnabled: boolean
xAxisLabelFormat: DemoXAxisLabelFormat
xAxisLabelFormatOptions: Array<SelectOption<DemoXAxisLabelFormat>>
xAxisLabelMultiplier: number
xAxisLabelFractionDigits: number
xAxisLabelTimeZone: DemoXAxisLabelTimeZone
xAxisLabelTimeZoneOptions: Array<SelectOption<DemoXAxisLabelTimeZone>>
xAxisLabelShowMilliseconds: boolean
frameBorderColor: string
frameBackgroundColor: string
frameBorderWidth: number

View File

@@ -0,0 +1,77 @@
import { computed, ref } from 'vue'
import type { WaveformXAxisLabelFormatter } from '../types'
export type DemoXAxisLabelFormat = 'number' | 'datetime'
export type DemoXAxisLabelTimeZone = 'local' | 'Asia/Shanghai' | 'UTC'
const formatOptions = [
{ label: '数值', value: 'number' as const },
{ label: '固定时间', value: 'datetime' as const },
]
const timeZoneOptions = [
{ label: '中国标准时间', value: 'Asia/Shanghai' as const },
{ label: '本地时区', value: 'local' as const },
{ label: 'UTC', value: 'UTC' as const },
]
function pad(value: number, length = 2): string {
return String(value).padStart(length, '0')
}
export function formatDemoTimestamp(
value: number,
timeZone: DemoXAxisLabelTimeZone,
showMilliseconds: boolean,
): string {
const offset = timeZone === 'Asia/Shanghai' ? 8 * 60 * 60 * 1000 : 0
const date = new Date(value + offset)
if (!Number.isFinite(date.getTime())) return String(value)
const useUtcFields = timeZone !== 'local'
const year = useUtcFields ? date.getUTCFullYear() : date.getFullYear()
const month = (useUtcFields ? date.getUTCMonth() : date.getMonth()) + 1
const day = useUtcFields ? date.getUTCDate() : date.getDate()
const hours = useUtcFields ? date.getUTCHours() : date.getHours()
const minutes = useUtcFields ? date.getUTCMinutes() : date.getMinutes()
const seconds = useUtcFields ? date.getUTCSeconds() : date.getSeconds()
const milliseconds = useUtcFields ? date.getUTCMilliseconds() : date.getMilliseconds()
const suffix = showMilliseconds ? `.${pad(milliseconds, 3)}` : ''
return `${year}-${pad(month)}-${pad(day)} ${pad(hours)}:${pad(minutes)}:${pad(seconds)}${suffix}`
}
export function useDemoXAxisLabelControls() {
const xAxisLabelFormatterEnabled = ref(false)
const xAxisLabelFormat = ref<DemoXAxisLabelFormat>('number')
const xAxisLabelMultiplier = ref(1)
const xAxisLabelFractionDigits = ref(3)
const xAxisLabelTimeZone = ref<DemoXAxisLabelTimeZone>('Asia/Shanghai')
const xAxisLabelShowMilliseconds = ref(false)
const xAxisLabelFormatter = computed<WaveformXAxisLabelFormatter | undefined>(() => {
if (!xAxisLabelFormatterEnabled.value) return undefined
if (xAxisLabelFormat.value === 'datetime') {
return (value) =>
formatDemoTimestamp(value, xAxisLabelTimeZone.value, xAxisLabelShowMilliseconds.value)
}
const multiplier = Number.isFinite(xAxisLabelMultiplier.value) ? xAxisLabelMultiplier.value : 1
const fractionDigits = Number.isFinite(xAxisLabelFractionDigits.value)
? Math.min(12, Math.max(0, Math.trunc(xAxisLabelFractionDigits.value)))
: 0
return (value) => (value * multiplier).toFixed(fractionDigits)
})
return {
controlModel: {
xAxisLabelFormatterEnabled,
xAxisLabelFormat,
xAxisLabelFormatOptions: formatOptions,
xAxisLabelMultiplier,
xAxisLabelFractionDigits,
xAxisLabelTimeZone,
xAxisLabelTimeZoneOptions: timeZoneOptions,
xAxisLabelShowMilliseconds,
},
xAxisLabelFormatter,
}
}

View File

@@ -0,0 +1,86 @@
import { flushPromises, mount } from '@vue/test-utils'
import { InputNumber, Select } from 'ant-design-vue'
import { describe, expect, it } from 'vitest'
import App from '../App.vue'
import { WaveformChart } from '../components'
import { formatDemoTimestamp } from './useDemoXAxisLabelControls'
describe('X-axis label demo controls', () => {
it('formats timestamps in fixed UTC and China-standard-time formats', () => {
expect(formatDemoTimestamp(0, 'UTC', false)).toBe('1970-01-01 00:00:00')
expect(formatDemoTimestamp(123, 'UTC', true)).toBe('1970-01-01 00:00:00.123')
expect(formatDemoTimestamp(0, 'Asia/Shanghai', false)).toBe('1970-01-01 08:00:00')
expect(formatDemoTimestamp(Number.NaN, 'UTC', false)).toBe('NaN')
})
it('configures numeric and timestamp labels from the sidebar', async () => {
const wrapper = mount(App)
await flushPromises()
const chart = wrapper.getComponent(WaveformChart)
expect(chart.props('axes')).toEqual({
x: { lineVisible: false },
y: { lineVisible: false },
})
expect(wrapper.find('[aria-label="自定义 X 轴 Label"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="X 轴 Label 小数位数"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="X 轴 Label 格式类型"]').exists()).toBe(false)
await wrapper.get('[aria-label="自定义 X 轴 Label"]').trigger('click')
await flushPromises()
const inputNumbers = wrapper.findAllComponents(InputNumber)
const multiplierInput = inputNumbers.find((input) =>
input.find('[aria-label="X 轴 Label 运算倍率"]').exists(),
)
const fractionDigitsInput = inputNumbers.find((input) =>
input.find('[aria-label="X 轴 Label 小数位数"]').exists(),
)
expect(multiplierInput).toBeDefined()
expect(fractionDigitsInput).toBeDefined()
expect(multiplierInput?.props('value')).toBe(1)
expect(fractionDigitsInput?.props('value')).toBe(3)
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toMatch(/\.000$/)
multiplierInput?.vm.$emit('update:value', 2)
fractionDigitsInput?.vm.$emit('update:value', 2)
await flushPromises()
const formatter = chart.props('axes')?.x?.labelFormatter
expect(formatter).toBeTypeOf('function')
expect(formatter?.(1.234, {} as never)).toBe('2.47')
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toMatch(/\.00$/)
const formatSelect = wrapper
.findAllComponents(Select)
.find((select) => select.find('[aria-label="X 轴 Label 格式类型"]').exists())
expect(formatSelect?.props('value')).toBe('number')
formatSelect?.vm.$emit('update:value', 'datetime')
await flushPromises()
expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="X 轴 Label 小数位数"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="X 轴 Label 时区"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="X 轴 Label 显示毫秒"]').exists()).toBe(true)
expect(chart.props('axes')?.x?.labelFormatter?.(0, {} as never)).toBe('1970-01-01 08:00:00')
await wrapper.get('[aria-label="X 轴 Label 显示毫秒"]').trigger('click')
await flushPromises()
expect(chart.props('axes')?.x?.labelFormatter?.(123, {} as never)).toBe(
'1970-01-01 08:00:00.123',
)
const timeZoneSelect = wrapper
.findAllComponents(Select)
.find((select) => select.find('[aria-label="X 轴 Label 时区"]').exists())
timeZoneSelect?.vm.$emit('update:value', 'UTC')
await flushPromises()
expect(chart.props('axes')?.x?.labelFormatter?.(123, {} as never)).toBe(
'1970-01-01 00:00:00.123',
)
await wrapper.get('[aria-label="自定义 X 轴 Label"]').trigger('click')
await flushPromises()
expect(chart.props('axes')?.x?.labelFormatter).toBeUndefined()
expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
wrapper.unmount()
})
})

View File

@@ -23,6 +23,9 @@ export type {
WaveformLegendOrientation,
WaveformLegendOptions,
WaveformFrameStyle,
WaveformXAxisLabelKind,
WaveformXAxisLabelFormatterContext,
WaveformXAxisLabelFormatter,
WaveformAxesOptions,
WaveformZeroLineOptions,
// 数据类型

View File

@@ -133,6 +133,14 @@ body {
grid-template-columns: minmax(0, 1fr) auto;
}
.x-axis-label-controls {
display: grid;
gap: 10px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #eaecf0;
}
.grid-line-color-picker,
.grid-line-color-picker .vc-color-wrap {
width: 48px;

View File

@@ -121,10 +121,31 @@ export interface WaveformFrameStyle {
backgroundColor?: string
}
export type WaveformXAxisLabelKind = 'tick' | 'start' | 'end'
/** Context passed to custom X-axis label formatters. */
export interface WaveformXAxisLabelFormatterContext {
kind: WaveformXAxisLabelKind
/** X coordinate in the source data, before time-unit conversion. */
rawValue: number
timeUnit: 's' | 'ms'
/** Current visible X domain in source coordinates. */
domain: [number, number]
/** Current visible X domain converted to the selected display unit. */
displayDomain: [number, number]
}
export type WaveformXAxisLabelFormatter = (
value: number,
context: WaveformXAxisLabelFormatterContext,
) => string
/** Controls axis baseline visibility while preserving tick marks and axis text. */
export interface WaveformAxesOptions {
x?: {
lineVisible?: boolean
/** Formats display-unit X values for ticks and visible-range endpoints. */
labelFormatter?: WaveformXAxisLabelFormatter
}
y?: {
lineVisible?: boolean

View File

@@ -18,6 +18,9 @@ export type {
WaveformLegendOrientation,
WaveformLegendOptions,
WaveformFrameStyle,
WaveformXAxisLabelKind,
WaveformXAxisLabelFormatterContext,
WaveformXAxisLabelFormatter,
WaveformAxesOptions,
WaveformZeroLineOptions,
} from './chart'

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { WaveformXAxisLabelFormatter, WaveformXAxisLabelFormatterContext } from '../types'
import {
formatAnnotationTime,
formatAxisTime,
@@ -9,6 +10,7 @@ import {
formatScientificAxisLabel,
formatTooltipNumber,
formatTooltipTime,
formatXAxisLabel,
resolveScientificAxisExponent,
shouldUseScientificAxisLabel,
} from './formatters'
@@ -55,20 +57,50 @@ describe('waveform number formatters', () => {
expect(formatScientificAxisExponent(0, 0)).toBeNull()
})
it('formats X-axis ticks and endpoints as plain integers in the selected display unit', () => {
it('formats X-axis ticks and endpoints as complete plain values in the display unit', () => {
const domain: [number, number] = [0, 1]
expect(formatAxisTime(0.5004, 'ms', domain)).toBe('500')
expect(formatAxisTime(0.5004, 'ms', domain)).toBe('500.4')
expect(formatEndpointTime(1, domain, 'ms')).toBe('1000')
expect(formatAxisTime(0.5, 's', domain)).toBe('1')
expect(formatAxisTime(0.5, 's', domain)).toBe('0.5')
expect(formatEndpointTime(1, domain, 's')).toBe('1')
const tinyDomain: [number, number] = [0, 0.000001]
expect(formatEndpointTime(0.000001, tinyDomain, 's')).toBe('0')
expect(formatAxisTime(-0.000001, 's', tinyDomain)).toBe('0')
expect(formatEndpointTime(0.000001, tinyDomain, 's')).toBe('0.000001')
expect(formatAxisTime(-0.000001, 's', tinyDomain)).toBe('-0.000001')
expect(formatAxisTime(-0, 's')).toBe('0')
expect(formatAxisTime(1e21, 's')).toBe('1000000000000000000000')
expect(formatAxisTime(Number.POSITIVE_INFINITY, 's')).toBe('Infinity')
})
it('passes display values and complete source context to X-axis label formatters', () => {
const domain: [number, number] = [0.125, 1.875]
const formatter: WaveformXAxisLabelFormatter = (value, context) =>
`${context.kind}:${value / 10}`
expect(formatXAxisLabel(0.5, domain, 'ms', 'tick', formatter)).toBe('tick:50')
expect(formatXAxisLabel(domain[0], domain, 'ms', 'start', formatter)).toBe('start:12.5')
expect(formatXAxisLabel(domain[1], domain, 'ms', 'end', formatter)).toBe('end:187.5')
let receivedContext: WaveformXAxisLabelFormatterContext | undefined
formatXAxisLabel(0.5, domain, 'ms', 'tick', (value, context) => {
receivedContext = context
return String(value)
})
expect(receivedContext).toEqual({
kind: 'tick',
rawValue: 0.5,
timeUnit: 'ms',
domain: [0.125, 1.875],
displayDomain: [125, 1875],
})
expect(() =>
formatXAxisLabel(0.5, domain, 's', 'tick', () => {
throw new Error('formatter failed')
}),
).toThrow('formatter failed')
})
it('formats tooltip and raw values for their display contexts', () => {
expect(formatTooltipNumber(12345.67891)).toBe('12,345.6789')
expect(formatTooltipNumber(-0)).toBe('0')

View File

@@ -1,3 +1,5 @@
import type { WaveformXAxisLabelFormatter, WaveformXAxisLabelKind } from '../types'
/**
* 时间单位类型
*/
@@ -22,10 +24,6 @@ const Y_AXIS_NUMBER_FORMATTER = new Intl.NumberFormat('zh-CN', {
const TOOLTIP_NUMBER_FORMATTER = new Intl.NumberFormat('zh-CN', {
maximumFractionDigits: 4,
})
const X_AXIS_TIME_FORMATTER = new Intl.NumberFormat('zh-CN', {
maximumFractionDigits: 0,
useGrouping: false,
})
function formatFixedNumber(value: number, precision: number): string {
const formatted = value.toFixed(Math.max(0, precision))
@@ -103,13 +101,6 @@ export function formatTooltipNumber(value: number): string {
return TOOLTIP_NUMBER_FORMATTER.format(value)
}
/** Format an X-axis time value as a plain integer without grouping separators. */
function formatAxisTimeValue(value: number): string {
if (!Number.isFinite(value)) return String(value)
const formatted = X_AXIS_TIME_FORMATTER.format(value)
return formatted === '-0' ? '0' : formatted
}
/** Convert a number to complete plain decimal text without forcing exponential notation. */
export function formatPlainNumber(value: number): string {
if (!Number.isFinite(value)) return String(value)
@@ -143,6 +134,26 @@ export function displayTime(value: number, timeUnit: TimeUnit): number {
return timeUnit === 'ms' ? value * 1000 : value
}
/** Format an X-axis value without changing its source coordinate. */
export function formatXAxisLabel(
rawValue: number,
domain: [number, number],
timeUnit: TimeUnit,
kind: WaveformXAxisLabelKind,
formatter?: WaveformXAxisLabelFormatter,
): string {
const value = displayTime(rawValue, timeUnit)
if (!formatter) return formatPlainNumber(value)
return formatter(value, {
kind,
rawValue,
timeUnit,
domain: [domain[0], domain[1]],
displayDomain: [displayTime(domain[0], timeUnit), displayTime(domain[1], timeUnit)],
})
}
/**
* 计算端点标签的小数位数
* @param domain 数据域 [最小值, 最大值]
@@ -158,7 +169,7 @@ export function endpointFractionDigits(domain: [number, number], timeUnit: TimeU
}
/**
* 将 X 轴端点时间格式化为当前显示单位下的普通整数
* 将 X 轴端点时间格式化为当前显示单位下的普通十进制文本
* @param value 时间值(秒)
* @param _domain 数据域(为保持现有调用签名而保留)
* @param timeUnit 时间单位
@@ -169,11 +180,11 @@ export function formatEndpointTime(
_domain: [number, number],
timeUnit: TimeUnit,
): string {
return formatAxisTimeValue(displayTime(value, timeUnit))
return formatXAxisLabel(value, _domain, timeUnit, 'end')
}
/**
* 将 X 轴时间刻度格式化为当前显示单位下的普通整数
* 将 X 轴时间刻度格式化为当前显示单位下的普通十进制文本
* @param value 时间值(秒)
* @param timeUnit 时间单位
* @param _domain 数据域(为保持现有调用签名而保留)
@@ -184,8 +195,7 @@ export function formatAxisTime(
timeUnit: TimeUnit,
_domain?: [number, number],
): string {
void _domain
return formatAxisTimeValue(displayTime(value, timeUnit))
return formatXAxisLabel(value, _domain ?? [value, value], timeUnit, 'tick')
}
/**

View File

@@ -11,6 +11,7 @@ export {
endpointFractionDigits,
formatEndpointTime,
formatAxisTime,
formatXAxisLabel,
formatTooltipTime,
formatAnnotationTime,
formatPlainNumber,