From 8987cae8469c61a0fc751b89550f90a360b67116 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 11:45:40 +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=E9=85=8D=E7=BD=AE=E7=BA=B5=E8=BD=B4=E7=AD=89=E5=88=86?= =?UTF-8?q?=E5=88=BB=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 +++- package.json | 2 +- .../core/fixedYDomainLayout.test.ts | 80 +++++++++++++++++-- src/components/core/layout.test.ts | 12 ++- src/components/core/layout.ts | 29 +++++-- src/components/core/trackLayoutBuilder.ts | 21 +++-- src/components/core/useWaveformLayout.ts | 11 ++- .../compactAndSharedHover.test.ts | 4 +- .../waveformChartCases/dataLifecycle.test.ts | 4 +- .../layoutAndDomains.test.ts | 26 +++++- src/types/chart.ts | 2 + 11 files changed, 171 insertions(+), 35 deletions(-) diff --git a/README.md b/README.md index 30be119..d450e2d 100644 --- a/README.md +++ b/README.md @@ -115,7 +115,7 @@ const data = ref({ | `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 | | `yDomains` | `Record` | 未设置 | 按 track/series ID 配置固定范围 | | `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 | -| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐与 X 轴 label 格式化 | +| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线、Y 轴分割数与 X 轴 label 格式化 | | `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 | | `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 | | `frameNumber` | `string \| number` | 未设置 | 图框水印内容 | @@ -208,7 +208,8 @@ import 'waveform-analysis/style.css' 范围优先级为 `trackId` 配置、`seriesId` 配置、全局 `yDomain`、数据自动范围。上下限必须 是两个有限且不相等的数字;倒序范围会自动调整为升序,无效配置会回退到下一优先级。 -固定范围会精确作为坐标域使用,不会经过 D3 的 `nice()` 扩展。 +固定范围作为 Y 轴刻度算法的初始范围;轴会按漂亮步长经过 D3 的 `nice()` 扩展,确保主刻度 +等间距。原始数据和传入的范围值不会被修改。 单值轴叠加模式会合并同一根轴上所有可见系列的有效范围;多值轴模式按系列分别使用配置, 超过四根轴后复用第 4 根轴的系列会取范围并集。隐藏系列不参与公共范围合并。 @@ -216,6 +217,16 @@ import 'waveform-analysis/style.css' 固定范围存在时,对应图框的 Y 轴不会被平移或视口重置覆盖;X 轴缩放、平移和重置保持原有 行为。运行时更新或移除 `yDomain` / `yDomains` 会立即重新布局,移除后恢复自动范围。 +### Y 轴自动等分 + +Y 轴默认显示 5 个主刻度(包含上下端点),并使用类似 ECharts 的 `1 / 2 / 5 × 10ⁿ` 漂亮 +步长,保证主刻度之间数值等间距。可以通过 `axes.y.splitNumber` 指定主刻度总数;每条 +Y 轴会独立计算: + +```vue + +``` + ### 缩放后按可视区间加载数据 组件支持 Plotly 风格的矩形框选缩放:在 zoom 模式下按住鼠标左键拖拽,松开后同时缩放 diff --git a/package.json b/package.json index 1f815c5..363f293 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "waveform-analysis", - "version": "0.1.40", + "version": "0.1.41", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/types/index.d.ts", diff --git a/src/components/core/fixedYDomainLayout.test.ts b/src/components/core/fixedYDomainLayout.test.ts index ff26f7f..d733c4f 100644 --- a/src/components/core/fixedYDomainLayout.test.ts +++ b/src/components/core/fixedYDomainLayout.test.ts @@ -35,7 +35,7 @@ function track(seriesList: DisplaySeries[]): DisplayTrack { } describe('fixed Y-domain layout', () => { - it('uses an exact global fixed domain without applying nice bounds', () => { + it('expands a global fixed domain to nice equal intervals', () => { const sourceTrack = track([series('a', 0, 100)]) const result = buildTrackLayouts({ cells: [ @@ -66,10 +66,80 @@ describe('fixed Y-domain layout', () => { showCompactEmptyTracks: false, })[0] - expect(result?.yScale.domain()).toEqual([3, 97]) - expect(result?.yAxes[0]?.tickValues).toContain(3) - expect(result?.yAxes[0]?.tickValues).toContain(97) - expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([3, 97]) + expect(result?.yScale.domain()).toEqual([0, 100]) + expect(result?.yAxes[0]?.tickValues).toContain(0) + expect(result?.yAxes[0]?.tickValues).toContain(100) + expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([0, 100]) + }) + + it('uses the configured split number for fixed domains', () => { + const result = buildTrackLayouts({ + cells: [ + { + slotIndex: 0, + row: 0, + column: 0, + left: 0, + top: 0, + width: 120, + height: 100, + plotHeight: 100, + cellHeight: 130, + xAxisBand: 30, + series: track([series('a', 0, 100)]), + }, + ], + grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} }, + displayMode: 'independent', + overlayMode: 'single-axis', + independentTransforms: [zoomIdentity], + sharedZoomDomain: [0, 1], + fixedYDomain: [3, 97], + yAxisSplitNumber: 5, + timeUnit: 'ms', + rendering: DEFAULT_WAVEFORM_RENDERING_OPTIONS, + hideSecondaryLabels: false, + yAxisLabelX: -50, + showCompactEmptyTracks: false, + })[0] + + expect(result?.yAxes[0]?.majorTicks).toEqual([0, 25, 50, 75, 100]) + }) + + it('defaults to five ticks and supports a two-tick axis', () => { + const build = (splitNumber?: number) => + buildTrackLayouts({ + cells: [ + { + slotIndex: 0, + row: 0, + column: 0, + left: 0, + top: 0, + width: 120, + height: 100, + plotHeight: 100, + cellHeight: 130, + xAxisBand: 30, + series: track([series('a', 3, 97)]), + }, + ], + grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} }, + displayMode: 'independent', + overlayMode: 'single-axis', + independentTransforms: [zoomIdentity], + sharedZoomDomain: [0, 1], + fixedYDomain: [3, 97], + yAxisSplitNumber: splitNumber, + timeUnit: 'ms', + rendering: DEFAULT_WAVEFORM_RENDERING_OPTIONS, + hideSecondaryLabels: false, + yAxisLabelX: -50, + showCompactEmptyTracks: false, + })[0]?.yAxes[0]?.majorTicks + + expect(build()).toHaveLength(5) + expect(build(2)).toEqual([0, 100]) }) it('resolves track, series, and global fixed-domain precedence', () => { diff --git a/src/components/core/layout.test.ts b/src/components/core/layout.test.ts index 32ec402..ca3e69a 100644 --- a/src/components/core/layout.test.ts +++ b/src/components/core/layout.test.ts @@ -9,6 +9,7 @@ import { findClosestTrackAtPointer, MAX_MULTI_Y_AXIS_COUNT, measureYAxisGroupClearance, + resolveYAxisTickCount, } from './layout' function series(id: string, minimum: number, maximum: number): DisplaySeries { @@ -119,8 +120,15 @@ describe('multi-value Y-axis grouping', () => { showCompactEmptyTracks: false, })[0] - expect(result?.yScale.domain()).toEqual([25, 75]) - expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([25, 75]) + expect(result?.yScale.domain()).toEqual([20, 80]) + expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([20, 80]) + }) + + it('normalizes configured Y-axis split counts', () => { + expect(resolveYAxisTickCount(100, 0)).toBe(2) + expect(resolveYAxisTickCount(100, 4.8)).toBe(4) + expect(resolveYAxisTickCount(100, Number.NaN)).toBe(5) + expect(resolveYAxisTickCount(220)).toBe(5) }) it('keeps every overlaid series on one axis in single-axis mode', () => { diff --git a/src/components/core/layout.ts b/src/components/core/layout.ts index 91be564..3cdc58b 100644 --- a/src/components/core/layout.ts +++ b/src/components/core/layout.ts @@ -20,6 +20,13 @@ const Y_AXIS_OUTER_PADDING = 4 const Y_AXIS_LABEL_GAP = 6 const Y_AXIS_LABEL_BAND_WIDTH = 24 +export function resolveYAxisTickCount(_plotHeight: number, splitNumber?: number): number { + if (typeof splitNumber === 'number' && Number.isFinite(splitNumber)) { + return Math.max(2, Math.floor(splitNumber)) + } + return 5 +} + export interface YAxisSeriesGroup { index: number side: 'left' | 'right' @@ -104,11 +111,16 @@ export function axisTextMetrics( nice = true, tickValues?: number[], unit?: string, + tickCount = 10, ): { tickTextWidth: number } { + const effectiveTickCount = + typeof tickCount === 'number' && Number.isFinite(tickCount) + ? Math.max(2, Math.floor(tickCount)) + : 10 const scale = scaleLinear(domain, [1, 0]) - if (nice) scale.nice() + if (nice) scale.nice(effectiveTickCount) const [axisMin, axisMax] = scale.domain() - const values = tickValues ?? Array.from(new Set([axisMin, ...scale.ticks(10), axisMax])) + const values = tickValues ?? scale.ticks(effectiveTickCount) const topTickValue = Math.max(...values) const maximumTickCharacters = Math.max( 1, @@ -121,9 +133,9 @@ export function axisTextMetrics( } } -export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number { +export function measureYAxisGroupClearance(group: YAxisSeriesGroup, tickCount?: number): number { return ( - axisTextMetrics(group.domain, !group.fixed, undefined, group.seriesList[0]?.unit) + axisTextMetrics(group.domain, true, undefined, group.seriesList[0]?.unit, tickCount) .tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + @@ -132,9 +144,9 @@ export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number { ) } -function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number { +function measureYAxisGroupTickClearance(group: YAxisSeriesGroup, tickCount?: number): number { return ( - axisTextMetrics(group.domain, !group.fixed, undefined, group.seriesList[0]?.unit) + axisTextMetrics(group.domain, true, undefined, group.seriesList[0]?.unit, tickCount) .tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING @@ -146,13 +158,14 @@ export function measureTrackYAxisClearance( overlayMode: WaveformOverlayMode, yDomain?: WaveformYDomain, yDomains?: Record, + tickCount?: number, ): { left: number; right: number } { return resolveYAxisSeriesGroups(track, overlayMode, yDomain, yDomains).reduce( (clearance, group) => { clearance[group.side] += overlayMode === 'multi-axis' || track.visibleSeries.length === 1 - ? measureYAxisGroupClearance(group) - : measureYAxisGroupTickClearance(group) + ? measureYAxisGroupClearance(group, tickCount) + : measureYAxisGroupTickClearance(group, tickCount) return clearance }, { left: 0, right: 0 }, diff --git a/src/components/core/trackLayoutBuilder.ts b/src/components/core/trackLayoutBuilder.ts index 97831cd..3157b4f 100644 --- a/src/components/core/trackLayoutBuilder.ts +++ b/src/components/core/trackLayoutBuilder.ts @@ -25,7 +25,7 @@ import { type GridCellGeometry, type NormalizedWaveformGridOptions, } from './grid' -import { axisTextMetrics, resolveYAxisSeriesGroups } from './layout' +import { axisTextMetrics, resolveYAxisSeriesGroups, resolveYAxisTickCount } from './layout' import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types' import { applyXDomainStrategy } from './xDomain' import { @@ -54,6 +54,7 @@ export interface BuildTrackLayoutsOptions { yDomains?: Record timeUnit: 's' | 'ms' xAxisLabelFormatter?: WaveformXAxisLabelFormatter + yAxisSplitNumber?: number rendering: ResolvedWaveformRenderingOptions hideSecondaryLabels: boolean yAxisLabelX: number @@ -124,22 +125,26 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou ) const sideOffsets = { left: 0, right: 0 } const yAxes: WaveformYAxisLayout[] = yAxisGroups.map((group) => { + const tickCount = resolveYAxisTickCount(cell.plotHeight, options.yAxisSplitNumber) + const niceCount = Math.max(1, tickCount - 1) const scale = scaleLinear(group.domain, [cell.plotHeight, 0]) - if (!group.fixed) scale.nice() - const majorTicks = scale.ticks(Math.max(2, Math.floor(cell.plotHeight / 55))) + scale.nice(niceCount) const [axisStart, axisEnd] = scale.domain() + const majorTicks = Array.from( + { length: tickCount }, + (_, index) => axisStart + ((axisEnd - axisStart) * index) / (tickCount - 1), + ) const showAxisEnd = options.displayMode !== 'compact' || cell.row === 0 const visibleMajorTicks = showAxisEnd ? majorTicks : majorTicks.filter((tick) => tick !== axisEnd) - const tickValues = Array.from( - new Set([axisStart, ...visibleMajorTicks, ...(showAxisEnd ? [axisEnd] : [])]), - ) + const tickValues = visibleMajorTicks const { tickTextWidth } = axisTextMetrics( - group.domain, - !group.fixed, + scale.domain() as [number, number], + false, tickValues, group.seriesList[0]?.unit, + tickCount, ) const clearance = tickTextWidth + diff --git a/src/components/core/useWaveformLayout.ts b/src/components/core/useWaveformLayout.ts index cfbcc56..d0c083e 100644 --- a/src/components/core/useWaveformLayout.ts +++ b/src/components/core/useWaveformLayout.ts @@ -114,8 +114,13 @@ export function useWaveformLayout(context: LayoutContext) { ) .map( (group) => - axisTextMetrics(group.domain, !group.fixed, undefined, group.seriesList[0]?.unit) - .tickTextWidth, + axisTextMetrics( + group.domain, + true, + undefined, + group.seriesList[0]?.unit, + props.axes?.y?.splitNumber, + ).tickTextWidth, ) const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText) const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING @@ -161,6 +166,7 @@ export function useWaveformLayout(context: LayoutContext) { props.overlayMode, props.yDomain, props.yDomains, + props.axes?.y?.splitNumber, ) return { left: Math.max(maximum.left, clearance.left), @@ -302,6 +308,7 @@ export function useWaveformLayout(context: LayoutContext) { : sharedYDomains.value, timeUnit: props.timeUnit, xAxisLabelFormatter: props.axes?.x?.labelFormatter, + yAxisSplitNumber: props.axes?.y?.splitNumber, rendering: renderingOptions.value, hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels, yAxisLabelX: yAxisMetrics.value.labelCenterX, diff --git a/src/components/waveformChartCases/compactAndSharedHover.test.ts b/src/components/waveformChartCases/compactAndSharedHover.test.ts index b9aa836..8b2214d 100644 --- a/src/components/waveformChartCases/compactAndSharedHover.test.ts +++ b/src/components/waveformChartCases/compactAndSharedHover.test.ts @@ -65,9 +65,9 @@ describe('WaveformChart', () => { ) expect(endTicks[0]).toHaveLength(1) - expect(Number(endTicks[0][0].text())).toBe(0.9) + expect(Number(endTicks[0][0].text())).toBe(1) expect(endTicks[1]).toHaveLength(1) - expect(Number(endTicks[1][0].text())).toBe(1.9) + expect(Number(endTicks[1][0].text())).toBe(2) expect(endTicks[2]).toHaveLength(0) expect(endTicks[3]).toHaveLength(0) }) diff --git a/src/components/waveformChartCases/dataLifecycle.test.ts b/src/components/waveformChartCases/dataLifecycle.test.ts index e40d1b3..438ef72 100644 --- a/src/components/waveformChartCases/dataLifecycle.test.ts +++ b/src/components/waveformChartCases/dataLifecycle.test.ts @@ -261,7 +261,7 @@ describe('WaveformChart', () => { }) expect(startTicks).toHaveLength(1) - expect(Number(startTicks[0].text())).toBe(0.1) + expect(Number(startTicks[0].text())).toBe(0) }, ) @@ -297,7 +297,7 @@ describe('WaveformChart', () => { { displayMode }, ) - const expectedEndValues = [0.9, 4.9] + const expectedEndValues = [1, 5] wrapper.findAll('.waveform-chart__track').forEach((track, index) => { const endTicks = track.findAll('.waveform-chart__axis--y .tick').filter((tick) => { const match = tick.attributes('transform')?.match(/translate\(0,\s*([\d.]+)\)/) diff --git a/src/components/waveformChartCases/layoutAndDomains.test.ts b/src/components/waveformChartCases/layoutAndDomains.test.ts index da42b71..cfc7dd0 100644 --- a/src/components/waveformChartCases/layoutAndDomains.test.ts +++ b/src/components/waveformChartCases/layoutAndDomains.test.ts @@ -6,6 +6,26 @@ import { resizeObservers } from '../../test/setup' import { gridSeries, mountSizedChart } from '../../test/waveformChart' describe('WaveformChart', () => { + it('applies the configured Y-axis split number', async () => { + const wrapper = await mountSizedChart( + { + kind: 'points', + points: [ + { x: 0, y: 3 }, + { x: 1, y: 97 }, + ], + }, + { axes: { y: { splitNumber: 5 } } }, + ) + + expect( + wrapper + .get('.waveform-chart__axis--y') + .findAll('.tick text') + .map((tick) => tick.text()), + ).toEqual(['0', '25', '50', '75', '100']) + }) + it('expands the Y-axis label gutter for signed values and long exponents', async () => { const wrapper = await mountSizedChart( { @@ -296,7 +316,7 @@ describe('WaveformChart', () => { expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000') }) - it('reacts to exact fixed Y-domain props and returns to automatic bounds', async () => { + it('reacts to nice fixed Y-domain props and returns to automatic bounds', async () => { const wrapper = await mountSizedChart({ kind: 'points', points: [ @@ -312,8 +332,8 @@ describe('WaveformChart', () => { await wrapper.setProps({ yDomain: [3, 97] }) await flushPromises() - expect(yTickLabels()).toContain('3') - expect(yTickLabels()).toContain('97') + expect(yTickLabels()).toContain('0') + expect(yTickLabels()).toContain('100') await wrapper.setProps({ yDomain: undefined }) await flushPromises() diff --git a/src/types/chart.ts b/src/types/chart.ts index c0c170f..68251bd 100644 --- a/src/types/chart.ts +++ b/src/types/chart.ts @@ -174,6 +174,8 @@ export interface WaveformAxesOptions { } y?: { lineVisible?: boolean + /** Target total number of equal Y-axis ticks, including endpoints. Defaults to 5. */ + splitNumber?: number } }