diff --git a/README.md b/README.md index b1fea85..20948fd 100644 --- a/README.md +++ b/README.md @@ -96,41 +96,41 @@ const data = ref({ ### Props -| Prop | 类型 | 默认值 | 说明 | -| --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | --------------------------------- | -| `data` | `WaveformData` | 必填 | 波形数据 | -| `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 | -| `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 | -| `timeUnit` | `'s' \| 'ms'` | `'ms'` | 坐标轴和 tooltip 展示单位 | -| `xLabel` / `yLabel` | `string` | `时间(timeUnit)` / `'幅值'` | 坐标轴名称 | -| `lineColor` | `string` | `'#0960bd'` | 单波形默认颜色 | -| `width` / `height` | `number` | 自适应 | 组件总尺寸,单位为 CSS 像素 | -| `zoomable` / `showTooltip` | `boolean` | `true` / `true` | 缩放和数值 tooltip 开关 | -| `pannable` | `boolean` | `false` | 空格拖拽平移开关 | -| `minZoomSpan` | `number` | 未设置 | 最小缩放跨度,使用原始 X 数据单位 | -| `minVisiblePoints` | `number` | `0` | 缩放后至少保留的不同 X 坐标数 | -| `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围 | -| `initialXDomains` | `Record` | 未设置 | 按 track/series ID 配置初始范围 | -| `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 | -| `yDomains` | `Record` | 未设置 | 按 track/series ID 配置固定范围 | -| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 | -| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐 | -| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 | -| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 | -| `frameNumber` | `string \| number` | 未设置 | 图框水印内容 | -| `zeroLine` | `WaveformZeroLineOptions` | `{ visible: false }` | 零值参考线显隐与样式 | -| `cleanView` | `boolean` | `false` | 保留波形、图框和刻度的净图模式 | -| `presentationMode` | `boolean` | `false` | 禁用绘图区交互的展示模式 | -| `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 | -| `annotationsVisible` | `boolean` | `true` | 标注图层显隐 | -| `interactionMode` | `'zoom' \| 'annotation'` | `'zoom'` | 左键交互模式 | -| `hiddenSeriesIds` | `string[]` | 未设置 | 受控隐藏系列 ID | -| `defaultHiddenSeriesIds` | `string[]` | `[]` | 非受控模式的初始隐藏系列 | +| Prop | 类型 | 默认值 | 说明 | +| --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | ---------------------------------- | +| `data` | `WaveformData` | 必填 | 波形数据 | +| `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 | +| `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 | +| `timeUnit` | `'s' \| 'ms'` | `'ms'` | 坐标轴和 tooltip 展示单位 | +| `xLabel` / `yLabel` | `string` | `时间(timeUnit)` / `'幅值'` | 坐标轴名称 | +| `lineColor` | `string` | `'#0960bd'` | 单波形默认颜色 | +| `width` / `height` | `number` | 自适应 | 组件总尺寸,单位为 CSS 像素 | +| `zoomable` / `showTooltip` | `boolean` | `true` / `true` | 缩放和数值 tooltip 开关 | +| `pannable` | `boolean` | `false` | 空格拖拽平移开关 | +| `minZoomSpan` | `number` | 未设置 | 最小缩放跨度,使用原始 X 数据单位 | +| `minVisiblePoints` | `number` | `0` | 缩放后至少保留的不同 X 坐标数 | +| `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围 | +| `initialXDomains` | `Record` | 未设置 | 按 track/series ID 配置初始范围 | +| `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 | +| `yDomains` | `Record` | 未设置 | 按 track/series ID 配置固定范围 | +| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 | +| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐与 X 轴 label 格式化 | +| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 | +| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 | +| `frameNumber` | `string \| number` | 未设置 | 图框水印内容 | +| `zeroLine` | `WaveformZeroLineOptions` | `{ visible: false }` | 零值参考线显隐与样式 | +| `cleanView` | `boolean` | `false` | 保留波形、图框和刻度的净图模式 | +| `presentationMode` | `boolean` | `false` | 禁用绘图区交互的展示模式 | +| `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 | +| `annotationsVisible` | `boolean` | `true` | 标注图层显隐 | +| `interactionMode` | `'zoom' \| 'annotation'` | `'zoom'` | 左键交互模式 | +| `hiddenSeriesIds` | `string[]` | 未设置 | 受控隐藏系列 ID | +| `defaultHiddenSeriesIds` | `string[]` | `[]` | 非受控模式的初始隐藏系列 | 所有公开类型均可从包入口导入,例如 `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 + +``` + +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 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。 标注框默认布局在采样点正上方,只做绘图区边界裁剪;文本框通过连接箭头指向标注位置,多个标注重叠时可通过拖动手动避让。 ## 事件 diff --git a/package.json b/package.json index a57c415..5bfe668 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/App.vue b/src/App.vue index 735f938..ec27c22 100644 --- a/src/App.vue +++ b/src/App.vue @@ -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('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([]) const annotationsVisible = ref(true) const cleanView = ref(false) @@ -112,7 +114,10 @@ const frameStyle = computed(() => ({ backgroundColor: frameBackgroundColor.value, })) const axes = computed(() => ({ - x: { lineVisible: xAxisLineVisible.value }, + x: { + lineVisible: xAxisLineVisible.value, + ...(xAxisLabelFormatter.value ? { labelFormatter: xAxisLabelFormatter.value } : {}), + }, y: { lineVisible: yAxisLineVisible.value }, })) const zeroLine = computed(() => ({ @@ -308,6 +313,7 @@ const controlPanelModel = reactive({ verticalGridColor, xAxisLineVisible, yAxisLineVisible, + ...xAxisLabelControlModel, frameBorderColor, frameBackgroundColor, frameBorderWidth, diff --git a/src/components/core/trackLayoutBuilder.ts b/src/components/core/trackLayoutBuilder.ts index cd3ba72..b409df5 100644 --- a/src/components/core/trackLayoutBuilder.ts +++ b/src/components/core/trackLayoutBuilder.ts @@ -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 yDomains?: Record 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 diff --git a/src/components/core/useWaveformLayout.ts b/src/components/core/useWaveformLayout.ts index 3c3e45c..505fb41 100644 --- a/src/components/core/useWaveformLayout.ts +++ b/src/components/core/useWaveformLayout.ts @@ -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, diff --git a/src/components/rendering/WaveformTrackAxes.vue b/src/components/rendering/WaveformTrackAxes.vue index cf75adc..bcc82b1 100644 --- a/src/components/rendering/WaveformTrackAxes.vue +++ b/src/components/rendering/WaveformTrackAxes.vue @@ -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, ], diff --git a/src/components/waveformChartCases/frameGridAndFormatting.test.ts b/src/components/waveformChartCases/frameGridAndFormatting.test.ts index ddfaa0f..e53913a 100644 --- a/src/components/waveformChartCases/frameGridAndFormatting.test.ts +++ b/src/components/waveformChartCases/frameGridAndFormatting.test.ts @@ -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) }) }) diff --git a/src/demo/DemoGridFrameControls.vue b/src/demo/DemoGridFrameControls.vue index 3bb718e..f0f13f6 100644 --- a/src/demo/DemoGridFrameControls.vue +++ b/src/demo/DemoGridFrameControls.vue @@ -96,6 +96,76 @@ const model = defineModel('model', { required: true }) +
+ + + +

图框样式

diff --git a/src/demo/types.ts b/src/demo/types.ts index d526f71..8cc3b67 100644 --- a/src/demo/types.ts +++ b/src/demo/types.ts @@ -14,6 +14,7 @@ import type { WaveformZeroLineOptions, WaveformZoomEndPayload, } from '../components' +import type { DemoXAxisLabelFormat, DemoXAxisLabelTimeZone } from './useDemoXAxisLabelControls' interface SelectOption { label: string @@ -44,6 +45,14 @@ export interface DemoControlPanelModel { verticalGridColor: string xAxisLineVisible: boolean yAxisLineVisible: boolean + xAxisLabelFormatterEnabled: boolean + xAxisLabelFormat: DemoXAxisLabelFormat + xAxisLabelFormatOptions: Array> + xAxisLabelMultiplier: number + xAxisLabelFractionDigits: number + xAxisLabelTimeZone: DemoXAxisLabelTimeZone + xAxisLabelTimeZoneOptions: Array> + xAxisLabelShowMilliseconds: boolean frameBorderColor: string frameBackgroundColor: string frameBorderWidth: number diff --git a/src/demo/useDemoXAxisLabelControls.ts b/src/demo/useDemoXAxisLabelControls.ts new file mode 100644 index 0000000..06221e3 --- /dev/null +++ b/src/demo/useDemoXAxisLabelControls.ts @@ -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('number') + const xAxisLabelMultiplier = ref(1) + const xAxisLabelFractionDigits = ref(3) + const xAxisLabelTimeZone = ref('Asia/Shanghai') + const xAxisLabelShowMilliseconds = ref(false) + const xAxisLabelFormatter = computed(() => { + 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, + } +} diff --git a/src/demo/xAxisLabelControls.test.ts b/src/demo/xAxisLabelControls.test.ts new file mode 100644 index 0000000..d278954 --- /dev/null +++ b/src/demo/xAxisLabelControls.test.ts @@ -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() + }) +}) diff --git a/src/index.ts b/src/index.ts index 67962d6..4cd8b7f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,9 @@ export type { WaveformLegendOrientation, WaveformLegendOptions, WaveformFrameStyle, + WaveformXAxisLabelKind, + WaveformXAxisLabelFormatterContext, + WaveformXAxisLabelFormatter, WaveformAxesOptions, WaveformZeroLineOptions, // 数据类型 diff --git a/src/styles.css b/src/styles.css index 25b7dd3..ac3feac 100644 --- a/src/styles.css +++ b/src/styles.css @@ -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; diff --git a/src/types/chart.ts b/src/types/chart.ts index a64ff08..68cb48d 100644 --- a/src/types/chart.ts +++ b/src/types/chart.ts @@ -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 diff --git a/src/types/index.ts b/src/types/index.ts index 91c3bfc..0adc13b 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -18,6 +18,9 @@ export type { WaveformLegendOrientation, WaveformLegendOptions, WaveformFrameStyle, + WaveformXAxisLabelKind, + WaveformXAxisLabelFormatterContext, + WaveformXAxisLabelFormatter, WaveformAxesOptions, WaveformZeroLineOptions, } from './chart' diff --git a/src/utils/formatters.test.ts b/src/utils/formatters.test.ts index 7115034..d2a234d 100644 --- a/src/utils/formatters.test.ts +++ b/src/utils/formatters.test.ts @@ -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') diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts index 70d8b4d..076aff9 100644 --- a/src/utils/formatters.ts +++ b/src/utils/formatters.ts @@ -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') } /** diff --git a/src/utils/index.ts b/src/utils/index.ts index cba43f1..cd98522 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -11,6 +11,7 @@ export { endpointFractionDigits, formatEndpointTime, formatAxisTime, + formatXAxisLabel, formatTooltipTime, formatAnnotationTime, formatPlainNumber,