From c5aa3c662afc2e64f38616ba5d02a826d4b6a4a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=90=AF=E6=BA=90?= Date: Tue, 4 Aug 2026 11:28:42 +0800 Subject: [PATCH] fix(chart): refine axis number formatting --- README.md | 2 +- package.json | 2 +- src/components/core/layout.test.ts | 15 ++- src/components/core/layout.ts | 30 ++---- src/components/core/trackLayoutBuilder.ts | 26 +----- src/components/core/types.ts | 3 - src/components/core/useWaveformLayout.ts | 32 +------ .../interaction/WaveformTooltip.test.ts | 16 ++++ .../rendering/WaveformTrackAxes.vue | 30 +----- .../compactAndSharedHover.test.ts | 14 +-- .../dataAndHoverPerformance.test.ts | 4 +- .../waveformChartCases/dataLifecycle.test.ts | 8 +- .../frameGridAndFormatting.test.ts | 51 +++++----- .../interactionAndAnnotationCreation.test.ts | 2 +- .../layoutAndDomains.test.ts | 30 +++--- .../multiAxisAndPagination.test.ts | 16 ++-- .../presentationAndAxes.test.ts | 10 +- .../viewportConstraintsAndReset.test.ts | 16 ++-- .../waveformChartCases/wheelZoomOut.test.ts | 6 +- src/utils/formatters.test.ts | 73 ++++++++------- src/utils/formatters.ts | 92 +++++++++---------- src/utils/index.ts | 1 - 22 files changed, 217 insertions(+), 262 deletions(-) diff --git a/README.md b/README.md index 9619ecb..b1fea85 100644 --- a/README.md +++ b/README.md @@ -603,7 +603,7 @@ async function importAnnotationFile(file: File) { 字段无效时会抛出 `TypeError`,不会返回部分结果。导入包含未知 `seriesId` 的标注是允许的, 对应曲线加载后会恢复显示。文件选择、错误提示和下载由业务层实现。 -X、Y 轴会根据各自完整显示域选择格式:最大绝对值在 `[0.01, 100)` 时显示两位普通小数;大于等于 `100`,或大于 `0` 且小于 `0.01` 时,刻度显示两位缩放值,并在轴末端单独显示共享倍率 `E±NN`。X 轴先按 `timeUnit` 转换为秒或毫秒再判断范围,多 Y 轴则分别计算倍率。tooltip 使用最多 4 位小数的本地化普通数字;标注编辑器的 X 坐标跟随 `timeUnit` 并固定 3 位小数,Y 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。 +X 轴刻度和左右端点先按 `timeUnit` 转换为秒或毫秒,再四舍五入为不带分组符的普通整数,不使用科学计数法。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 1731e1f..a57c415 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "waveform-analysis", - "version": "0.1.27", + "version": "0.1.28", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/types/index.d.ts", diff --git a/src/components/core/layout.test.ts b/src/components/core/layout.test.ts index 2462437..32ec402 100644 --- a/src/components/core/layout.test.ts +++ b/src/components/core/layout.test.ts @@ -192,7 +192,7 @@ describe('multi-value Y-axis grouping', () => { ]) }) - it('places left and right scientific exponents eight pixels outside their tick labels', () => { + it('reserves label clearance for exponent-prefixed ticks on both axis sides', () => { const layout = buildTrackLayouts({ cells: [ { @@ -206,7 +206,7 @@ describe('multi-value Y-axis grouping', () => { plotHeight: 300, cellHeight: 330, xAxisBand: 30, - series: track([series('left', 0, 254), series('right', 0, 254)]), + series: track([series('left', 1000, 3000), series('right', 1000, 3000)]), }, ], grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} }, @@ -222,14 +222,13 @@ describe('multi-value Y-axis grouping', () => { })[0] expect( - layout?.yAxes.map(({ side, x, exponentX, exponentLabel }) => ({ + layout?.yAxes.map(({ side, x, labelX }) => ({ side, - offset: Math.abs(exponentX - x), - exponentLabel, + labelOffset: Math.abs(labelX - x), })), ).toEqual([ - { side: 'left', offset: 43, exponentLabel: 'E+02' }, - { side: 'right', offset: 43, exponentLabel: 'E+02' }, + { side: 'left', labelOffset: 67 }, + { side: 'right', labelOffset: 67 }, ]) }) @@ -237,7 +236,7 @@ describe('multi-value Y-axis grouping', () => { const [group] = buildYAxisSeriesGroups(track([series('long', -1e120, 1e120)]), 'multi-axis') expect(group).toBeDefined() - expect(measureYAxisGroupClearance(group!)).toBe(119) + expect(measureYAxisGroupClearance(group!)).toBe(90) }) }) diff --git a/src/components/core/layout.ts b/src/components/core/layout.ts index e0b85a9..60d53dd 100644 --- a/src/components/core/layout.ts +++ b/src/components/core/layout.ts @@ -1,9 +1,9 @@ import { scaleLinear } from 'd3' import type { WaveformOverlayMode } from '../../types' -import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../../utils' +import { formatScientificAxisLabel, paddedDomain } from '../../utils' import type { DisplaySeries, DisplayTrack, TrackLayout } from './types' -import { MAX_MULTI_Y_AXIS_COUNT, Y_AXIS_EXPONENT_GAP } from './constants' +import { MAX_MULTI_Y_AXIS_COUNT } from './constants' import { mergeYDomains, resolveSeriesFixedYDomain, @@ -12,7 +12,7 @@ import { } from './yDomain' // 导出常量供外部使用 -export { MAX_MULTI_Y_AXIS_COUNT, Y_AXIS_EXPONENT_GAP } from './constants' +export { MAX_MULTI_Y_AXIS_COUNT } from './constants' const Y_AXIS_CHARACTER_WIDTH = 7 const Y_AXIS_TICK_PADDING = 7 @@ -102,36 +102,27 @@ export function resolveYAxisSeriesGroups( export function axisTextMetrics( domain: [number, number], nice = true, -): { - exponentLabel: string | null - exponentWidth: number - tickTextWidth: number -} { + tickValues?: number[], +): { tickTextWidth: number } { const scale = scaleLinear(domain, [1, 0]) if (nice) scale.nice() const [axisMin, axisMax] = scale.domain() - const values = scale.ticks(10) + const values = tickValues ?? Array.from(new Set([axisMin, ...scale.ticks(10), axisMax])) + const topTickValue = Math.max(...values) const maximumTickCharacters = Math.max( 1, - ...values.map((value) => formatScientificAxisLabel(value, { axisMin, axisMax }).length), + ...values.map( + (value) => formatScientificAxisLabel(value, { axisMin, axisMax, topTickValue }).length, + ), ) - const exponentLabel = formatScientificAxisExponent(axisMin, axisMax) return { - exponentLabel, - exponentWidth: exponentLabel ? exponentLabel.length * Y_AXIS_CHARACTER_WIDTH : 0, tickTextWidth: maximumTickCharacters * Y_AXIS_CHARACTER_WIDTH, } } -function axisExponentClearance(domain: [number, number], nice: boolean): number { - const { exponentLabel, exponentWidth } = axisTextMetrics(domain, nice) - return exponentLabel ? exponentWidth + Y_AXIS_EXPONENT_GAP : 0 -} - export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number { return ( axisTextMetrics(group.domain, !group.fixed).tickTextWidth + - axisExponentClearance(group.domain, !group.fixed) + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_BAND_WIDTH + @@ -142,7 +133,6 @@ export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number { function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number { return ( axisTextMetrics(group.domain, !group.fixed).tickTextWidth + - axisExponentClearance(group.domain, !group.fixed) + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING ) diff --git a/src/components/core/trackLayoutBuilder.ts b/src/components/core/trackLayoutBuilder.ts index 6183576..cd3ba72 100644 --- a/src/components/core/trackLayoutBuilder.ts +++ b/src/components/core/trackLayoutBuilder.ts @@ -13,7 +13,7 @@ import { type ResolvedWaveformRenderingOptions, } from '../../core/rendering' import type { WaveformDisplayMode, WaveformOverlayMode, WaveformPoint } from '../../types' -import { buildMinorTicks, formatAxisTimeExponent, formatEndpointTime } from '../../utils' +import { buildMinorTicks, formatEndpointTime } from '../../utils' import { getBottomRowCellIndexes, type GridCellGeometry, @@ -21,7 +21,6 @@ import { } from './grid' import { axisTextMetrics, resolveYAxisSeriesGroups } from './layout' import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types' -import { Y_AXIS_EXPONENT_GAP } from './constants' import { Y_AXIS_LABEL_BAND_WIDTH, Y_AXIS_LABEL_GAP, @@ -118,31 +117,16 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou const tickValues = Array.from( new Set([axisStart, ...visibleMajorTicks, ...(showAxisEnd ? [axisEnd] : [])]), ) - const { exponentLabel, exponentWidth, tickTextWidth } = axisTextMetrics( - group.domain, - !group.fixed, - ) - const exponentClearance = exponentLabel ? exponentWidth + Y_AXIS_EXPONENT_GAP : 0 + const { tickTextWidth } = axisTextMetrics(group.domain, !group.fixed, tickValues) const clearance = tickTextWidth + Y_AXIS_TICK_PADDING + - exponentClearance + Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_BAND_WIDTH + Y_AXIS_OUTER_PADDING const x = group.side === 'left' ? -sideOffsets.left : cell.width + sideOffsets.right - const exponentX = - x + - (group.side === 'left' - ? -(Y_AXIS_TICK_PADDING + tickTextWidth + Y_AXIS_EXPONENT_GAP) - : Y_AXIS_TICK_PADDING + tickTextWidth + Y_AXIS_EXPONENT_GAP) const labelDistance = - tickTextWidth + - Y_AXIS_TICK_PADDING + - exponentClearance + - exponentWidth + - Y_AXIS_LABEL_GAP + - Y_AXIS_LABEL_BAND_WIDTH / 2 + tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_BAND_WIDTH / 2 const labelX = x + (group.side === 'left' ? -labelDistance : labelDistance) sideOffsets[group.side] += clearance return { @@ -150,8 +134,6 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou side: group.side, x, labelX, - exponentX, - exponentLabel, scale, majorTicks, minorTicks: buildMinorTicks(majorTicks), @@ -168,7 +150,6 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou start: formatEndpointTime(domain[0], domain, options.timeUnit), end: formatEndpointTime(domain[1], domain, options.timeUnit), } - const xAxisExponent = formatAxisTimeExponent(domain, options.timeUnit) const leftClearance = endpointLabels.start.length * 7 + 10 const rightClearance = endpointLabels.end.length * 7 + 10 const xAxisTickValues = xMajorTicks.filter((tick) => { @@ -235,7 +216,6 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou yAxisTickValues, xAxisTickValues, endpointLabels, - xAxisExponent, path: seriesPaths[0]?.path ?? null, seriesPaths, gridLines: options.grid.trackLines[displayTrack.id] ?? { diff --git a/src/components/core/types.ts b/src/components/core/types.ts index ed375bd..e962798 100644 --- a/src/components/core/types.ts +++ b/src/components/core/types.ts @@ -51,8 +51,6 @@ export interface WaveformYAxisLayout { side: 'left' | 'right' x: number labelX: number - exponentX: number - exponentLabel: string | null scale: ScaleLinear majorTicks: number[] minorTicks: number[] @@ -109,7 +107,6 @@ export interface TrackLayout { yAxisTickValues: number[] xAxisTickValues: number[] endpointLabels: { start: string; end: string } - xAxisExponent: string | null path: string | null seriesPaths: TrackSeriesPath[] showXAxis: boolean diff --git a/src/components/core/useWaveformLayout.ts b/src/components/core/useWaveformLayout.ts index 090ca93..3c3e45c 100644 --- a/src/components/core/useWaveformLayout.ts +++ b/src/components/core/useWaveformLayout.ts @@ -2,7 +2,7 @@ import { scaleLinear, type ZoomTransform } from 'd3' import { computed, type ComputedRef, type Ref, type ShallowRef } from 'vue' import { resolveWaveformRenderingOptions } from '../../core' -import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../../utils' +import { paddedDomain } from '../../utils' import { layoutAnnotations, type AnnotationSeriesInfo, @@ -28,9 +28,9 @@ import { } from './grid' import { buildTrackLayouts, + axisTextMetrics, measureTrackYAxisClearance, resolveYAxisSeriesGroups, - Y_AXIS_EXPONENT_GAP, } from './layout' import type { DisplaySeries, DisplayTrack, TrackLayout } from './types' import type { PreparedWaveformSeries } from './useWaveformData' @@ -112,40 +112,18 @@ export function useWaveformLayout(context: LayoutContext) { .flatMap((track) => resolveYAxisSeriesGroups(track, props.overlayMode, props.yDomain, props.yDomains), ) - .map((group) => { - const scale = scaleLinear(group.domain, [1, 0]) - if (!group.fixed) scale.nice() - const [axisMin, axisMax] = scale.domain() - return { - exponentLabel: formatScientificAxisExponent(axisMin, axisMax), - tickLabels: scale - .ticks(10) - .map((value) => formatScientificAxisLabel(value, { axisMin, axisMax })), - } - }) - const maximumCharacters = Math.max( - 1, - ...axisText.flatMap(({ tickLabels }) => tickLabels).map((label) => label.length), - ) - const tickTextWidth = maximumCharacters * Y_AXIS_CHARACTER_WIDTH - const maximumExponentWidth = Math.max( - 0, - ...axisText.map(({ exponentLabel }) => (exponentLabel?.length ?? 0) * Y_AXIS_CHARACTER_WIDTH), - ) - const exponentClearance = maximumExponentWidth ? maximumExponentWidth + Y_AXIS_EXPONENT_GAP : 0 - const tickClearance = - tickTextWidth + Y_AXIS_TICK_PADDING + exponentClearance + Y_AXIS_OUTER_PADDING + .map((group) => axisTextMetrics(group.domain, !group.fixed).tickTextWidth) + const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText) + const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING const labelCenterX = -( Y_AXIS_TICK_PADDING + tickTextWidth + - exponentClearance + Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_BAND_WIDTH / 2 ) const fullClearance = tickTextWidth + Y_AXIS_TICK_PADDING + - exponentClearance + Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_BAND_WIDTH + Y_AXIS_OUTER_PADDING diff --git a/src/components/interaction/WaveformTooltip.test.ts b/src/components/interaction/WaveformTooltip.test.ts index 07e81d5..38489bf 100644 --- a/src/components/interaction/WaveformTooltip.test.ts +++ b/src/components/interaction/WaveformTooltip.test.ts @@ -91,6 +91,22 @@ describe('WaveformTooltip', () => { expect(wrapper.get('.waveform-tooltip__value').text()).toBe('-1,405.4932 A') }) + it('formats tooltip time with at most four decimal places', () => { + const wrapper = mount(WaveformTooltip, { + props: { + visible: true, + position: { x: 10, y: 10 }, + timeUnit: 's', + hoveredPoint: { x: 1.234567, y: 12 }, + seriesPoints: [], + containerWidth: 400, + containerHeight: 300, + }, + }) + + expect(wrapper.get('.waveform-tooltip__time').text()).toBe('s: 1.2346') + }) + it('omits the error label when both resolved errors are zero', () => { const point = { x: 1, y: 12 } const wrapper = mount(WaveformTooltip, { diff --git a/src/components/rendering/WaveformTrackAxes.vue b/src/components/rendering/WaveformTrackAxes.vue index bd88574..cf75adc 100644 --- a/src/components/rendering/WaveformTrackAxes.vue +++ b/src/components/rendering/WaveformTrackAxes.vue @@ -43,8 +43,11 @@ function renderAxes() { const element = yAxisElements.value[index] if (!element) return const [axisMin, axisMax] = axis.scale.domain() + const topTickValue = Math.max(...axis.tickValues) const yAxis = (axis.side === 'left' ? axisLeft(axis.scale) : axisRight(axis.scale)) - .tickFormat((value) => formatScientificAxisLabel(Number(value), { axisMin, axisMax })) + .tickFormat((value) => + formatScientificAxisLabel(Number(value), { axisMin, axisMax, topTickValue }), + ) .tickSize(-4) .tickPadding(7) .tickSizeOuter(0) @@ -136,17 +139,6 @@ watch( {{ track.endpointLabels.end }} - - - -