From 233b9b0aa2fe88b6286b59d8ad42feafee44eacc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=90=AF=E6=BA=90?= Date: Tue, 18 Aug 2026 10:06:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=9D=90=E6=A0=87=E8=BD=B4):=20=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E6=98=BE=E7=A4=BA=E7=BA=B5=E8=BD=B4=E5=8D=95=E4=BD=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 30 ++++++++++++++++++- package.json | 2 +- src/components/core/layout.ts | 9 ++++-- src/components/core/trackLayoutBuilder.ts | 7 ++++- src/components/core/useWaveformLayout.ts | 6 +++- .../rendering/WaveformTrackAxes.vue | 3 +- .../waveformChartCases/dataLifecycle.test.ts | 6 ++++ .../multiAxisAndPagination.test.ts | 9 +++++- src/utils/formatters.test.ts | 9 ++++++ src/utils/formatters.ts | 9 ++++-- 10 files changed, 78 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 4d1735d..30be119 100644 --- a/README.md +++ b/README.md @@ -298,6 +298,34 @@ chartRef.value?.setViewportDomain(trackDomain, trackIndex) Tooltip 每个系列按 `炮号:通道 (x:值 y:值)` 格式显示。`WaveformSeries.shotNo` 为空或未提供时, 炮号显示为“未配置炮号”;Tooltip 不显示单位和误差附加文本。 +### 纵轴单位 + +可在每个 `WaveformSeries` 上设置可选的 `unit`,单位会显示在对应 Y 轴的顶部刻度中: + +```ts +const chartData = { + kind: 'series', + series: [ + { + id: 'voltage', + name: '电压', + unit: 'V', + data: { + kind: 'points', + points: [ + { x: 0, y: 1200 }, + { x: 1, y: 3000 }, + ], + }, + }, + ], +} satisfies WaveformData +``` + +当 Y 轴使用科学计数法时,顶部刻度格式为 `E+03 (V) 3`;未使用科学计数法时,格式为 +`(V) 3`。单位为空、全为空白或未配置时,不显示 `(单位)`,例如仍显示为 `E+03 3` 或 `3`。 +多 Y 轴模式下,每根轴使用该轴首个 series 的单位。 + ### 线型、点型与误差棒 每条序列可以独立设置连线方式、数据点符号和误差棒: @@ -669,7 +697,7 @@ async function importAnnotationFile(file: File) { 字段无效时会抛出 `TypeError`,不会返回部分结果。导入包含未知 `seriesId` 的标注是允许的, 对应曲线加载后会恢复显示。文件选择、错误提示和下载由业务层实现。 -X 轴刻度和左右端点先按 `timeUnit` 转换为秒或毫秒,再显示为不带分组符和科学计数法的完整普通十进制值,并可通过 `axes.x.labelFormatter` 自定义。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`。如果 series 配置了 `unit`,顶部刻度会在倍率后追加 `(单位)`;没有科学计数法时也会显示 `(单位)`,多 Y 轴分别使用对应轴首个 series 的单位。tooltip 使用最多 4 位小数的本地化普通数字并省略无意义尾零;标注编辑器的 X 坐标跟随 `timeUnit` 并固定 3 位小数,Y 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。 标注框默认布局在采样点正上方,只做绘图区边界裁剪;文本框通过连接箭头指向标注位置,多个标注重叠时可通过拖动手动避让。 ## 事件 diff --git a/package.json b/package.json index 69aab14..1f815c5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "waveform-analysis", - "version": "0.1.39", + "version": "0.1.40", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/types/index.d.ts", diff --git a/src/components/core/layout.ts b/src/components/core/layout.ts index 60d53dd..91be564 100644 --- a/src/components/core/layout.ts +++ b/src/components/core/layout.ts @@ -103,6 +103,7 @@ export function axisTextMetrics( domain: [number, number], nice = true, tickValues?: number[], + unit?: string, ): { tickTextWidth: number } { const scale = scaleLinear(domain, [1, 0]) if (nice) scale.nice() @@ -112,7 +113,7 @@ export function axisTextMetrics( const maximumTickCharacters = Math.max( 1, ...values.map( - (value) => formatScientificAxisLabel(value, { axisMin, axisMax, topTickValue }).length, + (value) => formatScientificAxisLabel(value, { axisMin, axisMax, topTickValue, unit }).length, ), ) return { @@ -122,7 +123,8 @@ export function axisTextMetrics( export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number { return ( - axisTextMetrics(group.domain, !group.fixed).tickTextWidth + + axisTextMetrics(group.domain, !group.fixed, undefined, group.seriesList[0]?.unit) + .tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_BAND_WIDTH + @@ -132,7 +134,8 @@ export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number { function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number { return ( - axisTextMetrics(group.domain, !group.fixed).tickTextWidth + + axisTextMetrics(group.domain, !group.fixed, undefined, group.seriesList[0]?.unit) + .tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING ) diff --git a/src/components/core/trackLayoutBuilder.ts b/src/components/core/trackLayoutBuilder.ts index db1a04e..97831cd 100644 --- a/src/components/core/trackLayoutBuilder.ts +++ b/src/components/core/trackLayoutBuilder.ts @@ -135,7 +135,12 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou const tickValues = Array.from( new Set([axisStart, ...visibleMajorTicks, ...(showAxisEnd ? [axisEnd] : [])]), ) - const { tickTextWidth } = axisTextMetrics(group.domain, !group.fixed, tickValues) + const { tickTextWidth } = axisTextMetrics( + group.domain, + !group.fixed, + tickValues, + group.seriesList[0]?.unit, + ) const clearance = tickTextWidth + Y_AXIS_TICK_PADDING + diff --git a/src/components/core/useWaveformLayout.ts b/src/components/core/useWaveformLayout.ts index c7b7298..cfbcc56 100644 --- a/src/components/core/useWaveformLayout.ts +++ b/src/components/core/useWaveformLayout.ts @@ -112,7 +112,11 @@ export function useWaveformLayout(context: LayoutContext) { .flatMap((track) => resolveYAxisSeriesGroups(track, props.overlayMode, props.yDomain, props.yDomains), ) - .map((group) => axisTextMetrics(group.domain, !group.fixed).tickTextWidth) + .map( + (group) => + axisTextMetrics(group.domain, !group.fixed, undefined, group.seriesList[0]?.unit) + .tickTextWidth, + ) const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText) const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING const labelCenterX = -( diff --git a/src/components/rendering/WaveformTrackAxes.vue b/src/components/rendering/WaveformTrackAxes.vue index bcc82b1..7616821 100644 --- a/src/components/rendering/WaveformTrackAxes.vue +++ b/src/components/rendering/WaveformTrackAxes.vue @@ -44,9 +44,10 @@ function renderAxes() { if (!element) return const [axisMin, axisMax] = axis.scale.domain() const topTickValue = Math.max(...axis.tickValues) + const unit = axis.seriesList[0]?.unit const yAxis = (axis.side === 'left' ? axisLeft(axis.scale) : axisRight(axis.scale)) .tickFormat((value) => - formatScientificAxisLabel(Number(value), { axisMin, axisMax, topTickValue }), + formatScientificAxisLabel(Number(value), { axisMin, axisMax, topTickValue, unit }), ) .tickSize(-4) .tickPadding(7) diff --git a/src/components/waveformChartCases/dataLifecycle.test.ts b/src/components/waveformChartCases/dataLifecycle.test.ts index 1ba3215..e40d1b3 100644 --- a/src/components/waveformChartCases/dataLifecycle.test.ts +++ b/src/components/waveformChartCases/dataLifecycle.test.ts @@ -158,6 +158,12 @@ describe('WaveformChart', () => { expect(paths[0].attributes('data-series-name')).toBe('BT2_2M') const yAxisLabels = wrapper.findAll('.waveform-chart__y-axis-label') expect(yAxisLabels.map((label) => label.text())).toEqual(['BT2_2M', 'BT1_2M']) + expect( + wrapper + .findAll('.waveform-chart__axis--y .tick text') + .map((label) => label.text()) + .some((label) => label.includes('(T)')), + ).toBe(true) expect(yAxisLabels.map((label) => label.attributes('fill'))).toEqual(['#0960bd', '#ff7f0e']) const labelX = Number( yAxisLabels[0].attributes('transform')?.match(/^translate\(([-\d.]+),/)?.[1], diff --git a/src/components/waveformChartCases/multiAxisAndPagination.test.ts b/src/components/waveformChartCases/multiAxisAndPagination.test.ts index fe67c64..c09ba70 100644 --- a/src/components/waveformChartCases/multiAxisAndPagination.test.ts +++ b/src/components/waveformChartCases/multiAxisAndPagination.test.ts @@ -15,6 +15,7 @@ describe('WaveformChart', () => { { trackId: 'shared-frame', name: '普通量程', + unit: 'V', data: { kind: 'points', points: [ @@ -26,6 +27,7 @@ describe('WaveformChart', () => { { trackId: 'shared-frame', name: '大量程', + unit: 'A', data: { kind: 'points', points: [ @@ -37,6 +39,7 @@ describe('WaveformChart', () => { { trackId: 'shared-frame', name: '小量程', + unit: 'T', data: { kind: 'points', points: [ @@ -57,7 +60,11 @@ describe('WaveformChart', () => { .map((label) => label.text()) .filter((label) => label.startsWith('E')), ), - ).toEqual([[], [expect.stringMatching(/^E\+03 /)], [expect.stringMatching(/^E-04 /)]]) + ).toEqual([ + [], + [expect.stringMatching(/^E\+03 \(A\) /)], + [expect.stringMatching(/^E-04 \(T\) /)], + ]) }) it('reprojects annotations with the Y axis assigned to their series', async () => { diff --git a/src/utils/formatters.test.ts b/src/utils/formatters.test.ts index 96540cb..af5111d 100644 --- a/src/utils/formatters.test.ts +++ b/src/utils/formatters.test.ts @@ -40,6 +40,15 @@ describe('waveform number formatters', () => { expect(formatScientificAxisExponent(0.0001, 0.0003)).toBe('E-04') }) + it('appends a unit to the top tick with or without scientific notation', () => { + expect(formatScientificAxisLabel(3000, { axisMin: 1000, axisMax: 3000, unit: 'V' })).toBe( + 'E+03 (V) 3', + ) + expect(formatScientificAxisLabel(3, { axisMin: 0, axisMax: 3, unit: 'A' })).toBe('(A) 3') + expect(formatScientificAxisLabel(2, { axisMin: 0, axisMax: 3, unit: 'A' })).toBe('2') + expect(formatScientificAxisLabel(3, { axisMin: 0, axisMax: 3, unit: ' ' })).toBe('3') + }) + it('derives the exponent from the largest absolute endpoint', () => { expect(resolveScientificAxisExponent(-9000, -1000)).toBe(3) expect(resolveScientificAxisExponent(-100_000, -3000)).toBe(5) diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts index ef638ee..a502e62 100644 --- a/src/utils/formatters.ts +++ b/src/utils/formatters.ts @@ -11,6 +11,7 @@ export interface ScientificAxisLabelOptions { axisMin?: number axisMax?: number topTickValue?: number + unit?: string } /** @deprecated Use ScientificAxisLabelOptions. */ @@ -71,9 +72,11 @@ export function formatScientificAxisLabel( const scaledValue = exponent === null ? value : value / 10 ** exponent const formattedValue = formatYAxisNumber(scaledValue) const topTickValue = options.topTickValue ?? options.axisMax - return exponent !== null && value === topTickValue - ? `${formatExponent(exponent)} ${formattedValue}` - : formattedValue + if (value !== topTickValue) return formattedValue + const unit = options.unit?.trim() + const unitLabel = unit ? `(${unit}) ` : '' + const exponentLabel = exponent === null ? '' : `${formatExponent(exponent)} ` + return `${exponentLabel}${unitLabel}${formattedValue}` } /** Return the shared E-style multiplier for an axis, or null for a plain axis. */