diff --git a/README.md b/README.md index 2d79b11..5a8098e 100644 --- a/README.md +++ b/README.md @@ -96,37 +96,37 @@ 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 坐标数 | +| 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 配置初始范围(可超出数据) | -| `xDomainStrategy` | `WaveformXDomainStrategy` | `{ type: 'data' }` | 自动 X 轴视口范围策略 | -| `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[]` | `[]` | 非受控模式的初始隐藏系列 | +| `xDomainStrategy` | `WaveformXDomainStrategy` | `{ type: 'data' }` | 自动 X 轴视口范围策略 | +| `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`、 @@ -415,7 +415,8 @@ Demo 左侧控制面板提供标题实时预览,可配置标题名称、显隐 ### 图例与曲线显隐 -`legend.backgroundColor` 设置多曲线图例的背景颜色。该字段接受任意有效 CSS 颜色值, +每个图框独立管理自己的图例:图框内有两条或更多曲线时显示图例,只有一条曲线时不显示。 +`legend.backgroundColor` 设置图例的背景颜色。该字段接受任意有效 CSS 颜色值, 可通过 `rgba(...)` 或 `hsla(...)` 中的 alpha 通道调整透明度: ```vue diff --git a/package.json b/package.json index f12e3cd..e602663 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "waveform-analysis", - "version": "0.1.37", + "version": "0.1.38", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/types/index.d.ts", diff --git a/src/App.test.ts b/src/App.test.ts index bcf783d..2481ff6 100644 --- a/src/App.test.ts +++ b/src/App.test.ts @@ -235,7 +235,7 @@ describe('App workspace layout', { timeout: 20_000 }, () => { tracks.map((track) => track.findAll('.waveform-chart__series').map((item) => item.attributes('data-series-name')), ), - ).toEqual([['正弦基波'], ['谐波扰动'], ['阻尼振荡'], ['阶跃响应']]) + ).toEqual([['正弦基波'], ['谐波扰动', '谐波对比'], ['阻尼振荡'], ['阶跃响应']]) expect( tracks .slice(1) @@ -253,6 +253,7 @@ describe('App workspace layout', { timeout: 20_000 }, () => { expect(simulatedSeries.map((item) => item.name)).toEqual([ '正弦基波', '谐波扰动', + '谐波对比', '阻尼振荡', '阶跃响应', '脉冲响应', @@ -321,7 +322,6 @@ describe('App workspace layout', { timeout: 20_000 }, () => { const styleSelect = frameControls.findAllComponents(Select)[0] expect(colorPickers).toHaveLength(2) - const initialFrames = wrapper.findAll('.waveform-chart__plot-frame') expect(initialFrames.length > 1 && initialFrames.every((frame) => frame.attributes('stroke-width') === '2')).toBe(true) diff --git a/src/components/waveformChartCases/legendVisibility.test.ts b/src/components/waveformChartCases/legendVisibility.test.ts index 014e803..781581c 100644 --- a/src/components/waveformChartCases/legendVisibility.test.ts +++ b/src/components/waveformChartCases/legendVisibility.test.ts @@ -16,13 +16,30 @@ function annotationEditorExists() { } describe('WaveformChart', () => { + it('shows legends when a track contains at least two series', async () => { + const oneSeries = visibilitySeries() + if (oneSeries.kind === 'series') oneSeries.series = oneSeries.series.slice(0, 1) + const withoutLegend = await mountSizedChart(oneSeries, { + grid: { rowCount: 1, columnCount: 1 }, + }) + expect(withoutLegend.findAll('.waveform-chart__legend')).toHaveLength(0) + + const twoSeries = visibilitySeries() + if (twoSeries.kind === 'series') twoSeries.series = twoSeries.series.slice(0, 2) + const withLegend = await mountSizedChart(twoSeries, { + grid: { rowCount: 1, columnCount: 1 }, + }) + expect(withLegend.findAll('.waveform-chart__legend')).toHaveLength(1) + expect(withLegend.find('.waveform-chart__legend').attributes('data-position')).toBe('top-right') + }) + it('resolves legend positions by stable track id across pages', async () => { const wrapper = await mountSizedChart( { kind: 'series', - series: Array.from({ length: 8 }, (_, index) => ({ + series: Array.from({ length: 12 }, (_, index) => ({ id: `series-${index}`, - trackId: `frame-${Math.floor(index / 2)}`, + trackId: `frame-${Math.floor(index / 3)}`, name: `series ${index}`, data: { kind: 'points' as const, @@ -75,9 +92,9 @@ describe('WaveformChart', () => { const wrapper = await mountSizedChart( { kind: 'series', - series: Array.from({ length: 4 }, (_, index) => ({ + series: Array.from({ length: 6 }, (_, index) => ({ id: `series-${index}`, - trackId: `frame-${Math.floor(index / 2)}`, + trackId: `frame-${Math.floor(index / 3)}`, name: `series ${index}`, data: { kind: 'points', @@ -112,7 +129,7 @@ describe('WaveformChart', () => { }) const items = wrapper.findAll('.waveform-chart__legend-item') - expect(items).toHaveLength(2) + expect(items).toHaveLength(3) expect(items.every((item) => item.attributes('disabled') !== undefined)).toBe(true) expect(wrapper.get('.waveform-legend__panel').classes()).not.toContain( 'waveform-legend__panel--interactive', @@ -128,7 +145,7 @@ describe('WaveformChart', () => { overlayMode: 'multi-axis', }) - expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(2) + expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(3) expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true) const annotationLayer = wrapper.get('.waveform-annotation-layer').element const legendLayer = wrapper.get('.waveform-chart__legend-layer').element @@ -143,8 +160,8 @@ describe('WaveformChart', () => { expect( wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), - ).toEqual(['low']) - expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(1) + ).toEqual(['low', 'mid']) + expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(2) expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(false) expect(wrapper.findAll('.waveform-chart__legend-item')[1].classes()).toContain('is-hidden') expect(wrapper.findAll('.waveform-chart__legend-item')[1].attributes('aria-pressed')).toBe( @@ -170,12 +187,12 @@ describe('WaveformChart', () => { ) flushAnimationFrames() await flushPromises() - expect(wrapper.findAll('.waveform-chart__tooltip-series')).toHaveLength(1) + expect(wrapper.findAll('.waveform-chart__tooltip-series')).toHaveLength(2) expect(wrapper.get('.waveform-chart__tooltip-series').text()).toContain('低量程') await wrapper.findAll('.waveform-chart__legend-item')[1].trigger('click') await flushPromises() - expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(2) + expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(3) expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true) expect(wrapper.emitted('series-visibility-change')?.at(-1)).toEqual([ { seriesId: 'high', visible: true, hiddenSeriesIds: [] }, @@ -191,20 +208,20 @@ describe('WaveformChart', () => { expect( wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), - ).toEqual(['low']) + ).toEqual(['low', 'mid']) await wrapper.findAll('.waveform-chart__legend-item')[0].trigger('click') expect(wrapper.emitted('update:hidden-series-ids')?.at(-1)).toEqual([ ['high', 'temporarily-absent', 'low'], ]) expect( wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), - ).toEqual(['low']) + ).toEqual(['low', 'mid']) await wrapper.setProps({ hiddenSeriesIds: ['low'] }) await flushPromises() expect( wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), - ).toEqual(['high']) + ).toEqual(['high', 'mid']) }) it('retains uncontrolled visibility by stable ID and clears removed IDs', async () => { @@ -223,7 +240,7 @@ describe('WaveformChart', () => { await flushPromises() expect( wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), - ).toEqual(['low']) + ).toEqual(['mid', 'low']) await wrapper.setProps({ data: { @@ -234,12 +251,12 @@ describe('WaveformChart', () => { await flushPromises() await wrapper.setProps({ data: original }) await flushPromises() - expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(2) + expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(3) }) it('keeps a recoverable legend and stops chart interaction when every series is hidden', async () => { const wrapper = await mountSizedChart(visibilitySeries(), { - defaultHiddenSeriesIds: ['low', 'high'], + defaultHiddenSeriesIds: ['low', 'high', 'mid'], grid: { rowCount: 1, columnCount: 1 }, legend: { interactive: true }, overlayMode: 'multi-axis', @@ -248,7 +265,7 @@ describe('WaveformChart', () => { expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(0) expect(wrapper.findAll('.waveform-chart__axis')).toHaveLength(0) expect(wrapper.findAll('.waveform-chart__overlay')).toHaveLength(0) - expect(wrapper.findAll('.waveform-chart__legend-item')).toHaveLength(2) + expect(wrapper.findAll('.waveform-chart__legend-item')).toHaveLength(3) expect(wrapper.get('.waveform-track__no-visible-series').text()).toBe('暂无可见曲线') await wrapper.findAll('.waveform-chart__legend-item')[0].trigger('click') diff --git a/src/components/waveformChartCases/multiAxisAndPagination.test.ts b/src/components/waveformChartCases/multiAxisAndPagination.test.ts index 1812090..fe67c64 100644 --- a/src/components/waveformChartCases/multiAxisAndPagination.test.ts +++ b/src/components/waveformChartCases/multiAxisAndPagination.test.ts @@ -177,6 +177,18 @@ describe('WaveformChart', () => { ], }, }, + { + id: 'reference', + trackId: 'frame-1', + name: 'REF_CH_1', + data: { + kind: 'points', + points: [ + { x: 0, y: 0.25 }, + { x: 1, y: 1.25 }, + ], + }, + }, ], }, { frameNumber: 1, grid: { rowCount: 2, columnCount: 1 } }, @@ -186,7 +198,7 @@ describe('WaveformChart', () => { expect(tracks).toHaveLength(2) expect( tracks[0].findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), - ).toEqual(['primary', 'comparison']) + ).toEqual(['primary', 'comparison', 'reference']) expect( tracks[1].findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), ).toEqual(['second-frame']) @@ -203,12 +215,13 @@ describe('WaveformChart', () => { expect(legend.findAll('.waveform-chart__legend-item').map((item) => item.text())).toEqual([ 'BT2_2M', 'TEST_CH_1', + 'REF_CH_1', ]) expect( legend .findAll('.waveform-legend__swatch') .map((swatch) => swatch.get('path').attributes('stroke')), - ).toEqual(['#0960bd', '#2ca02c']) + ).toEqual(['#0960bd', '#2ca02c', '#d62728']) expect(wrapper.findAll('.waveform-chart__watermark').map((item) => item.text())).toEqual([ '1', '2', @@ -232,10 +245,11 @@ describe('WaveformChart', () => { await flushPromises() const tooltipSeries = wrapper.findAll('.waveform-chart__tooltip-series') - expect(tooltipSeries).toHaveLength(2) + expect(tooltipSeries).toHaveLength(3) expect(tooltipSeries.map((item) => item.text())).toEqual([ expect.stringContaining('BT2_2M'), expect.stringContaining('TEST_CH_1'), + expect.stringContaining('REF_CH_1'), ]) }) @@ -268,6 +282,18 @@ describe('WaveformChart', () => { ], }, }, + { + id: 'third', + trackId: 'shared', + name: 'third', + data: { + kind: 'points', + points: [ + { x: 0, y: 2 }, + { x: 1, y: 3 }, + ], + }, + }, ], }, { grid: { rowCount: 1, columnCount: 1 } }, diff --git a/src/components/waveformChartCases/presentationMode.test.ts b/src/components/waveformChartCases/presentationMode.test.ts index 140197d..3a2b2ab 100644 --- a/src/components/waveformChartCases/presentationMode.test.ts +++ b/src/components/waveformChartCases/presentationMode.test.ts @@ -42,6 +42,19 @@ const presentationData: WaveformData = { ], }, }, + { + id: 'mid', + trackId: 'shared', + name: '中量程', + data: { + kind: 'points', + points: [ + { x: 0, y: 50 }, + { x: 1, y: 100 }, + { x: 2, y: 75 }, + ], + }, + }, { id: 'other', name: '其他通道', @@ -150,9 +163,9 @@ describe('WaveformChart presentation mode', () => { cancelable: true, }), ) - wrapper.get('.waveform-chart__svg').element.dispatchEvent( - new MouseEvent('dblclick', { bubbles: true, cancelable: true }), - ) + wrapper + .get('.waveform-chart__svg') + .element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })) await wrapper.get('[data-annotation-id="note"]').trigger('contextmenu', { clientX: width / 2, clientY: height / 2, diff --git a/src/data/simulatedWaveforms.test.ts b/src/data/simulatedWaveforms.test.ts index ee52a43..dc06672 100644 --- a/src/data/simulatedWaveforms.test.ts +++ b/src/data/simulatedWaveforms.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest' import { createSimulatedWaveformData } from './simulatedWaveforms' describe('simulated waveform data', () => { - it('creates deterministic, finite six-channel data', () => { + it('creates deterministic, finite seven-channel data with a two-series second frame', () => { const first = createSimulatedWaveformData() const second = createSimulatedWaveformData() @@ -11,8 +11,12 @@ describe('simulated waveform data', () => { expect(first.kind).toBe('series') if (first.kind !== 'series') return - expect(first.series).toHaveLength(6) - expect(new Set(first.series.map((series) => series.id)).size).toBe(6) + expect(first.series).toHaveLength(7) + expect(new Set(first.series.map((series) => series.id)).size).toBe(7) + const secondFrame = first.series.filter( + (series) => series.trackId === 'simulated-harmonic-frame', + ) + expect(secondFrame).toHaveLength(2) expect(new Set(first.series.map((series) => series.shotNo))).toEqual(new Set(['13300'])) first.series.forEach((series) => { expect(series.data.kind).toBe('points') diff --git a/src/data/simulatedWaveforms.ts b/src/data/simulatedWaveforms.ts index 7b87451..db2e139 100644 --- a/src/data/simulatedWaveforms.ts +++ b/src/data/simulatedWaveforms.ts @@ -13,7 +13,7 @@ type ErrorGenerator = ( interface SimulatedSeriesDefinition extends Pick< WaveformSeries, - 'id' | 'shotNo' | 'name' | 'unit' | 'lineType' | 'pointType' | 'errorBar' + 'id' | 'trackId' | 'shotNo' | 'name' | 'unit' | 'color' | 'lineType' | 'pointType' | 'errorBar' > { signal: SignalGenerator errors?: ErrorGenerator @@ -60,6 +60,7 @@ const seriesDefinitions: SimulatedSeriesDefinition[] = [ }, { id: 'simulated-harmonic', + trackId: 'simulated-harmonic-frame', shotNo: '13300', name: '谐波扰动', unit: 'V', @@ -68,6 +69,18 @@ const seriesDefinitions: SimulatedSeriesDefinition[] = [ signal: (time) => 0.9 * Math.sin(TWO_PI * 0.55 * time) + 0.28 * Math.sin(TWO_PI * 2.2 * time + 0.4), }, + { + id: 'simulated-harmonic-reference', + trackId: 'simulated-harmonic-frame', + shotNo: '13300', + name: '谐波对比', + unit: 'V', + color: '#2ca02c', + lineType: 'linear', + pointType: 'none', + signal: (time) => + 0.62 * Math.sin(TWO_PI * 0.55 * time + 0.55) + 0.18 * Math.sin(TWO_PI * 2.2 * time - 0.2), + }, { id: 'simulated-damped', shotNo: '13300', diff --git a/src/test/waveformChart.ts b/src/test/waveformChart.ts index 755c21f..8e5d12a 100644 --- a/src/test/waveformChart.ts +++ b/src/test/waveformChart.ts @@ -58,6 +58,18 @@ export function visibilitySeries(): WaveformData { ], }, }, + { + id: 'mid', + trackId: 'shared-frame', + name: '中量程', + data: { + kind: 'points', + points: [ + { x: 0, y: 100 }, + { x: 1, y: 200 }, + ], + }, + }, ], } }