From efb685646ae92296f629cb0afa41da361694022d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=90=AF=E6=BA=90?= Date: Mon, 20 Jul 2026 17:57:52 +0800 Subject: [PATCH] feat(chart): add series styling and visibility controls --- README.md | 58 +- src/App.test.ts | 85 ++- src/App.vue | 117 +++- src/components/WaveformChart.test.ts | 662 +++++++++++++++++- src/components/WaveformChart.vue | 364 +++++++--- src/components/annotation/markup.test.ts | 28 + src/components/annotation/markup.ts | 13 +- src/components/annotation/types.ts | 3 +- src/components/core/layout.test.ts | 166 ++++- src/components/core/layout.ts | 110 ++- src/components/core/types.ts | 19 +- src/components/core/useWaveformData.ts | 26 +- src/components/data/types.ts | 4 + src/components/index.ts | 3 + .../interaction/WaveformTooltip.test.ts | 85 +++ .../interaction/WaveformTooltip.vue | 29 +- src/components/rendering/WaveformLegend.vue | 118 +++- .../rendering/WaveformSeriesLayer.vue | 117 ++++ src/components/rendering/WaveformTrack.vue | 107 ++- src/components/rendering/index.ts | 1 + src/components/rendering/seriesStyle.ts | 98 +++ src/core/data.ts | 69 +- src/core/index.ts | 3 +- src/core/rendering.test.ts | 88 ++- src/core/rendering.ts | 107 +++ src/index.ts | 4 + src/test/setup.ts | 28 + src/types/chart.ts | 12 + src/types/data.ts | 31 + src/types/index.ts | 4 + vite.config.ts | 6 + 31 files changed, 2356 insertions(+), 209 deletions(-) create mode 100644 src/components/interaction/WaveformTooltip.test.ts create mode 100644 src/components/rendering/WaveformSeriesLayer.vue create mode 100644 src/components/rendering/seriesStyle.ts diff --git a/README.md b/README.md index 906d5e7..4dd51b9 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,36 @@ import { WaveformChart } from './index' 多通道数据应为每个 `WaveformSeries` 提供稳定的 `id`。内部时间坐标始终使用秒, `timeUnit` 只控制坐标轴和 tooltip 的显示单位。 +### 线型、点型与误差棒 + +每条序列可以独立设置连线方式、数据点符号和误差棒: + +```ts +const series = { + id: 'temperature', + name: '温度', + lineType: 'step-end', + pointType: 'circle', + errorBar: { visible: true, width: 1.5, capWidth: 8 }, + data: { + kind: 'points', + points: [ + { x: 0, y: 12, error: 0.5 }, + { x: 1, y: 15, lowerError: 0.4, upperError: 0.8 }, + ], + }, +} satisfies WaveformSeries +``` + +`lineType` 支持 `none`、`linear`、`step-start`、`step-middle` 和 `step-end`;兼容值 +`step-after` 与 `step-end` 等价。三个阶梯值分别在区间起点、中点和终点跳变。`pointType` +支持 `none`、`circle`、`square`、`triangle` 和 `diamond`。默认使用普通直线且不显示数据点; +设置 `lineType: 'none'` 可以隐藏数据点之间的连接线,只保留点符号和误差棒;将其改为 +`linear` 或阶梯类型即可同时显示对应连接线。误差棒仅在 `errorBar.visible` 为 `true` 时显示, +并参与 Y 轴范围计算;当误差棒可见时,`lineType` 和 `pointType` 可以同时为 `none`,用于展示 +纯误差棒。只有连接线、点符号和误差棒全部关闭时才会回退为普通直线。`lowerError`、 +`upperError` 分别覆盖对称的 `error`,图例会同步显示实际线型、点型和误差棒样式。 + ### 叠加与多值轴 为多条曲线设置相同的 `trackId`,可将它们叠加到同一图框。`overlayMode` 控制叠加 @@ -166,23 +196,38 @@ Demo 左侧控制面板提供标题实时预览,可配置标题名称、显隐 对应的公开类型为 `WaveformFrameStyle`。默认边框颜色为 `#1f2937`、线宽为 `1`、线型为 `solid`,背景透明。`borderWidth` 为 `0` 时隐藏边框;非有限值或负数会回退到默认线宽。 -### 图例样式 +### 图例与曲线显隐 `legend.backgroundColor` 设置多曲线图例的背景颜色。该字段接受任意有效 CSS 颜色值, 可通过 `rgba(...)` 或 `hsla(...)` 中的 alpha 通道调整透明度: ```vue + + ``` 未配置或传入空字符串时,图例背景默认使用 `rgba(255, 255, 255, 0.7)`。 +`legend.interactive` 默认为 `false`;开启后可以单击或使用键盘操作图例项切换曲线显隐。 +调用方可通过 `hiddenSeriesIds` 和 `update:hidden-series-ids` 控制状态,也可使用 +`defaultHiddenSeriesIds` 设置非受控模式的初始隐藏项。隐藏状态同步作用于坐标轴、tooltip、 +悬浮点和标注交互;允许隐藏全部曲线,并可通过保留的图例恢复显示。 + +显隐状态以规范化后的 `series.id` 为键。要在数据刷新和重新排序后稳定保留状态,每个系列都应 +提供全图唯一且稳定的显式 `id`;自动生成的索引 ID 或重复 ID 添加的后缀不保证跨排序稳定。 ## 大数据渲染 @@ -198,11 +243,20 @@ Demo 左侧控制面板提供标题实时预览,可配置标题名称、显隐 downsample: true, downsampleThreshold: 2000, maxPointsPerPixel: 4, + pointMinSpacing: 10, + errorBarMinSpacing: 12, }" /> ``` -降采样仅作用于 SVG path。最近点查询、tooltip、标注插值和受控数据不会损失精度。 +全量视图只绘制均匀分布的真实数据点,放大后会自动恢复更多源标记。`pointMinSpacing` 和 +`errorBarMinSpacing` 分别控制点符号和误差棒的最小水平间距,单位为 CSS 像素。两者同时 +显示时共用一批采样点,并采用两个间距中的较大值,确保误差棒与对应点符号保持共心;仅显示 +一类装饰时仍使用各自的间距。仅显示一类装饰时可将对应间距设为 `0`;两者同时显示时需将 +两个间距都设为 `0` 才会关闭共同限制。设置 `downsample: false` 会关闭曲线和装饰的全部降采样。 + +降采样仅作用于 SVG 中的曲线、点符号和误差棒。点符号和误差棒在每个系列中分别合并为 +单个 SVG path;最近点查询、tooltip、标注插值、Y 轴误差范围和受控数据不会损失精度。 ## 采样点标注 diff --git a/src/App.test.ts b/src/App.test.ts index b21ab0c..c798e32 100644 --- a/src/App.test.ts +++ b/src/App.test.ts @@ -5,7 +5,7 @@ import { ColorPicker } from 'vue3-colorpicker' import App from './App.vue' -describe('App workspace layout', () => { +describe('App workspace layout', { timeout: 20_000 }, () => { it('places controls in the sidebar beside the chart', async () => { const wrapper = mount(App) await flushPromises() @@ -64,22 +64,95 @@ describe('App workspace layout', () => { wrapper.unmount() }) - it('renders three additional sample series in the first frame', async () => { + it('renders the requested point-only and line-only examples in the first frame', async () => { const wrapper = mount(App) await flushPromises() - const firstFrameLines = wrapper - .get('.waveform-chart__track[data-track-index="0"]') - .findAll('.waveform-chart__line') + const firstFrame = wrapper.get('.waveform-chart__track[data-track-index="0"]') + const firstFrameSeries = firstFrame.findAll('.waveform-chart__series') - expect(firstFrameLines.map((line) => line.attributes('data-series-name'))).toEqual([ + expect(firstFrameSeries.map((series) => series.attributes('data-series-name'))).toEqual([ 'BT2_2M', 'TEST_CH_1', 'TEST_CH_3', 'TEST_CH_4', 'TEST_CH_5', + '纯点无线', + '纯线无点', ]) + const pointsOnlySeries = firstFrame.get('.waveform-chart__series[data-series-name="纯点无线"]') + expect(pointsOnlySeries.find('.waveform-chart__line').exists()).toBe(false) + expect(pointsOnlySeries.get('.waveform-chart__points').attributes('data-point-type')).toBe( + 'circle', + ) + + const testChannelFour = firstFrame.get('.waveform-chart__series[data-series-name="TEST_CH_4"]') + expect(testChannelFour.get('.waveform-chart__line').attributes('data-line-type')).toBe('linear') + expect(testChannelFour.get('.waveform-chart__points').attributes('data-point-type')).toBe( + 'circle', + ) + expect(testChannelFour.find('.waveform-chart__error-bars').exists()).toBe(false) + + const lineOnlySeries = firstFrame.get('.waveform-chart__series[data-series-name="纯线无点"]') + expect(lineOnlySeries.get('.waveform-chart__line').attributes('data-line-type')).toBe('linear') + expect(lineOnlySeries.find('.waveform-chart__points').exists()).toBe(false) + + const triangleSeries = firstFrame.get('.waveform-chart__series[data-series-name="BT2_2M"]') + expect(triangleSeries.find('.waveform-chart__line').exists()).toBe(false) + expect(triangleSeries.get('.waveform-chart__points').attributes('data-point-type')).toBe( + 'triangle', + ) + expect(triangleSeries.get('.waveform-chart__error-bar').attributes('stroke')).toBe('#0960bd') + + const triangleLegendItem = firstFrame + .findAll('.waveform-chart__legend-item') + .find((item) => item.text().includes('BT2_2M')) + expect(triangleLegendItem).toBeDefined() + const triangleSwatch = triangleLegendItem!.get('.waveform-legend__swatch') + expect(triangleSwatch.find('.waveform-legend__line').exists()).toBe(false) + expect(triangleSwatch.get('.waveform-legend__error-bar').attributes()).toMatchObject({ + d: 'M9 2H17M13 2V14M9 14H17', + stroke: '#0960bd', + 'stroke-width': '1.5', + }) + expect(triangleSwatch.get('.waveform-legend__point').attributes()).toMatchObject({ + fill: '#0960bd', + transform: 'translate(13 8)', + }) + + wrapper.unmount() + }) + + it('renders the three ECharts-style step modes in frame two', async () => { + const wrapper = mount(App) + await flushPromises() + + const secondFrame = wrapper.get('.waveform-chart__track[data-track-index="1"]') + const series = secondFrame.findAll('.waveform-chart__series') + expect(series.map((item) => item.attributes('data-series-name'))).toEqual([ + 'Step Start', + 'Step Middle', + 'Step End', + ]) + expect( + secondFrame.findAll('.waveform-chart__line').map((line) => line.attributes('data-line-type')), + ).toEqual(['step-start', 'step-middle', 'step-end']) + expect(secondFrame.findAll('.waveform-chart__points')).toHaveLength(3) + const legendItems = secondFrame.findAll('.waveform-chart__legend-item') + expect(legendItems).toHaveLength(3) + expect(legendItems.map((item) => item.get('.waveform-legend__line').attributes('d'))).toEqual([ + 'M1 8H25', + 'M1 8H25', + 'M1 8H25', + ]) + expect( + legendItems.map((item) => item.get('.waveform-legend__point').attributes('fill')), + ).toEqual(['#5470c6', '#91cc75', '#505372']) + expect( + legendItems.map((item) => item.get('.waveform-legend__point').attributes('transform')), + ).toEqual(['translate(13 8)', 'translate(13 8)', 'translate(13 8)']) + wrapper.unmount() }) diff --git a/src/App.vue b/src/App.vue index 8bbcf50..6a6d892 100644 --- a/src/App.vue +++ b/src/App.vue @@ -77,6 +77,7 @@ const interactionMode = ref('zoom') const legendPosition = ref('top-right') const legendOrientation = ref('auto') const legendBackgroundColor = ref('rgba(255, 255, 255, 0.7)') +const hiddenSeriesIds = ref([]) const titleVisible = ref(true) const titleText = ref(`Shot:${sourceRows[0]?.shot ?? 4712}`) const titleAlign = ref>('center') @@ -129,8 +130,20 @@ const frameStyle = computed(() => ({ backgroundColor: frameBackgroundColor.value, })) -const waveformSeries: WaveformSeries[] = sourceRows.map((row) => { +const seriesStylePresets: Array> = [ + { lineType: 'none', pointType: 'triangle', errorBar: { visible: true } }, + { lineType: 'linear', pointType: 'none' }, + { lineType: 'step-after', pointType: 'circle', errorBar: { visible: true } }, + { lineType: 'linear', pointType: 'diamond', errorBar: { visible: true } }, +] + +const waveformSeries: WaveformSeries[] = sourceRows.map((row, seriesIndex) => { const pointCount = Math.min(row.time.length, row.data.length) + const presetStyle = seriesStylePresets[seriesIndex % seriesStylePresets.length]! + const style: Pick = + row.chnl === 'TEST_CH_4' + ? { lineType: 'linear', pointType: 'circle', errorBar: { visible: false } } + : presetStyle return { id: String(row.chnl_id), trackId: @@ -139,17 +152,107 @@ const waveformSeries: WaveformSeries[] = sourceRows.map((row) => { : undefined, name: row.chnl, unit: row.dat_unit, + ...style, data: { kind: 'points', - points: Array.from({ length: pointCount }, (_, index) => ({ - x: row.time[index] / 1000, - y: row.data[index], - })), + points: Array.from({ length: pointCount }, (_, index) => { + const y = row.data[index] + const error = Math.max(Math.abs(y) * 0.08, 0.005) + return { + x: row.time[index] / 1000, + y, + ...(style.errorBar?.visible + ? seriesIndex % 2 === 0 + ? { lowerError: error * 0.65, upperError: error } + : { error } + : {}), + } + }), }, } }) -const chartData: WaveformData = { kind: 'series', series: waveformSeries } +const stepDemoValues = [ + { + id: 'step-demo-start', + name: 'Step Start', + color: '#5470c6', + lineType: 'step-start', + values: [120, 132, 101, 134, 90, 230, 210], + }, + { + id: 'step-demo-middle', + name: 'Step Middle', + color: '#91cc75', + lineType: 'step-middle', + values: [220, 282, 201, 234, 290, 430, 410], + }, + { + id: 'step-demo-end', + name: 'Step End', + color: '#505372', + lineType: 'step-end', + values: [450, 432, 401, 454, 590, 530, 510], + }, +] as const + +const stepDemoSeries: WaveformSeries[] = stepDemoValues.map((series) => ({ + id: series.id, + trackId: 'step-demo', + name: series.name, + color: series.color, + lineType: series.lineType, + pointType: 'circle', + data: { + kind: 'points', + points: series.values.map((y, index) => ({ x: index / 1000, y })), + }, +})) + +const frameOneTrackId = String(importedSourceRows[0]?.chnl_id ?? 'frame-one') +const frameOneDemoSource = importedSourceRows[0] +const basicCurveDemoSeries: WaveformSeries[] = frameOneDemoSource + ? [ + { + id: 'basic-points-only-demo', + trackId: frameOneTrackId, + name: '纯点无线', + color: '#d4380d', + lineType: 'none', + pointType: 'circle', + data: { + kind: 'points', + points: frameOneDemoSource.data.map((value, index) => ({ + x: frameOneDemoSource.time[index]! / 1000, + y: value * 1.18 + Math.sin(index / 12) * 0.006, + })), + }, + }, + { + id: 'basic-line-only-demo', + trackId: frameOneTrackId, + name: '纯线无点', + color: '#00796b', + lineType: 'linear', + pointType: 'none', + data: { + kind: 'points', + points: frameOneDemoSource.data.map((value, index) => ({ + x: frameOneDemoSource.time[index]! / 1000, + y: value * 0.82 - Math.sin(index / 16) * 0.006, + })), + }, + }, + ] + : [] +const frameOneSeries = waveformSeries.filter( + (series) => series.id === frameOneTrackId || series.trackId === frameOneTrackId, +) +const remainingSeries = waveformSeries.filter((series) => !frameOneSeries.includes(series)) +const chartData: WaveformData = { + kind: 'series', + series: [...frameOneSeries, ...basicCurveDemoSeries, ...stepDemoSeries, ...remainingSeries], +} const titleOptions = computed(() => ({ visible: titleVisible.value, text: titleText.value, @@ -468,12 +571,14 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleWindowKeydown) position: legendPosition, orientation: legendOrientation, backgroundColor: legendBackgroundColor, + interactive: true, }" :frame-style="frameStyle" :frame-number="frameWatermarkVisible ? 1 : undefined" v-model:annotations="annotations" v-model:annotations-visible="annotationsVisible" v-model:interaction-mode="interactionMode" + v-model:hidden-series-ids="hiddenSeriesIds" /> diff --git a/src/components/WaveformChart.test.ts b/src/components/WaveformChart.test.ts index beaf933..e93d60e 100644 --- a/src/components/WaveformChart.test.ts +++ b/src/components/WaveformChart.test.ts @@ -1,8 +1,10 @@ import { flushPromises, mount } from '@vue/test-utils' import { describe, expect, it } from 'vitest' -import { resizeObservers } from '../test/setup' +import { flushAnimationFrames, pendingAnimationFrameCount, resizeObservers } from '../test/setup' import WaveformChart from './WaveformChart.vue' +import { prepareWaveformSeries } from './core/useWaveformData' +import { waveformLegendErrorBarPath, waveformLegendLinePath } from './rendering/seriesStyle' import { normalizeWaveformData, normalizeWaveformSeries, type WaveformData } from './waveform' async function mountSizedChart(data: WaveformData, extraProps = {}) { @@ -50,6 +52,80 @@ describe('normalizeWaveformData', () => { expect(normalizeWaveformData({ kind: 'samples', values: [1, 2], sampleRate: 0 })).toEqual([]) }) + it('normalizes errors and preserves a pure error-bar series', () => { + const [series] = normalizeWaveformSeries({ + kind: 'series', + series: [ + { + name: 'styled', + lineType: 'none', + pointType: 'none', + errorBar: { visible: true, width: -1, capWidth: Number.NaN }, + data: { + kind: 'points', + points: [ + { x: 0, y: 2, error: 1, lowerError: -1, upperError: 2 }, + { x: 1, y: 3, error: Number.NaN }, + ], + }, + }, + ], + }) + + expect(series).toMatchObject({ + lineType: 'none', + pointType: 'none', + errorBar: { visible: true, width: 1.5, capWidth: 8 }, + points: [ + { x: 0, y: 2, error: 1, upperError: 2 }, + { x: 1, y: 3 }, + ], + }) + }) + + it('falls back to a line only when every series visual is disabled', () => { + const [series] = normalizeWaveformSeries({ + kind: 'series', + series: [ + { + name: 'invisible', + lineType: 'none', + pointType: 'none', + errorBar: { visible: false }, + data: { kind: 'points', points: [{ x: 0, y: 1 }] }, + }, + ], + }) + + expect(series).toMatchObject({ + lineType: 'linear', + pointType: 'none', + errorBar: { visible: false }, + }) + }) + + it('includes visible error bounds in the prepared Y domain', () => { + const [series] = prepareWaveformSeries({ + kind: 'series', + series: [ + { + name: 'errors', + errorBar: { visible: true }, + data: { + kind: 'points', + points: [ + { x: 0, y: 2, lowerError: 3, upperError: 4 }, + { x: 1, y: 3 }, + ], + }, + }, + ], + }) + + expect(series?.yDomain[0]).toBeLessThanOrEqual(-1) + expect(series?.yDomain[1]).toBeGreaterThanOrEqual(6) + }) + it('normalizes multiple named series and removes empty series', () => { expect( normalizeWaveformSeries({ @@ -74,12 +150,28 @@ describe('normalizeWaveformData', () => { name: 'BT2_2M', unit: 'T', color: undefined, + lineType: 'linear', + pointType: 'none', + errorBar: { visible: false, width: 1.5, capWidth: 8 }, points: [{ x: 1, y: 2 }], }, ]) }) }) +describe('legend series geometry', () => { + it('keeps line samples centered and clamps error-bar caps to the swatch', () => { + expect(waveformLegendLinePath('linear')).toBe('M1 8H25') + expect(waveformLegendLinePath('step-start')).toBe('M1 8H25') + expect(waveformLegendLinePath('step-middle')).toBe('M1 8H25') + expect(waveformLegendLinePath('step-end')).toBe('M1 8H25') + expect(waveformLegendLinePath('step-after')).toBe('M1 8H25') + expect(waveformLegendLinePath('none')).toBeNull() + expect(waveformLegendErrorBarPath(10)).toBe('M8 2H18M13 2V14M8 14H18') + expect(waveformLegendErrorBarPath(100)).toBe('M1 2H25M13 2V14M1 14H25') + }) +}) + describe('WaveformChart', () => { const gridSeries = (count: number): WaveformData => ({ kind: 'series', @@ -96,6 +188,232 @@ describe('WaveformChart', () => { })), }) + it('places start, middle, and end step transitions at the expected X positions', async () => { + const lineTypes = ['step-start', 'step-middle', 'step-end', 'step-after'] as const + const wrapper = await mountSizedChart({ + kind: 'series', + series: lineTypes.map((lineType) => ({ + id: lineType, + trackId: 'steps', + name: lineType, + lineType, + data: { + kind: 'points', + points: [ + { x: 0, y: 0 }, + { x: 2, y: 10 }, + ], + }, + })), + }) + const pathCoordinates = (lineType: (typeof lineTypes)[number]) => + Array.from( + ( + wrapper.get(`.waveform-chart__line[data-series-id="${lineType}"]`).attributes('d') ?? '' + ).matchAll(/[ML]([\d.-]+),([\d.-]+)/g), + (match) => ({ x: Number(match[1]), y: Number(match[2]) }), + ) + + const start = pathCoordinates('step-start') + const middle = pathCoordinates('step-middle') + const end = pathCoordinates('step-end') + const after = pathCoordinates('step-after') + expect(start.map((point) => point.x)).toEqual([start[0]?.x, start[0]?.x, start.at(-1)?.x]) + expect(middle[1]?.x).toBe(middle[2]?.x) + expect(middle[1]?.x).toBe((middle[0]!.x + middle.at(-1)!.x) / 2) + expect(end.map((point) => point.x)).toEqual([end[0]?.x, end.at(-1)?.x, end.at(-1)?.x]) + expect(after).toEqual(end) + + wrapper.unmount() + }) + + it('renders per-series lines, point symbols, error bars, and matching legend swatches', async () => { + const wrapper = await mountSizedChart({ + kind: 'series', + series: [ + { + id: 'triangle-errors', + trackId: 'styled-track', + name: '三角误差', + lineType: 'none', + pointType: 'triangle', + errorBar: { visible: true }, + data: { + kind: 'points', + points: [ + { x: 0, y: 1, error: 0.25 }, + { x: 1, y: 2, error: 0.5 }, + ], + }, + }, + { + id: 'line-only', + trackId: 'styled-track', + name: '纯线', + lineType: 'linear', + pointType: 'none', + data: { + kind: 'points', + points: [ + { x: 0, y: 2 }, + { x: 1, y: 3 }, + ], + }, + }, + { + id: 'step-errors', + trackId: 'styled-track', + name: '阶梯误差', + lineType: 'step-after', + pointType: 'circle', + errorBar: { visible: true, color: '#222222', width: 2, capWidth: 10 }, + data: { + kind: 'points', + points: [ + { x: 0, y: 3, lowerError: 0.5, upperError: 1 }, + { x: 1, y: 4 }, + ], + }, + }, + { + id: 'errors-only', + trackId: 'styled-track', + name: '纯误差棒', + lineType: 'none', + pointType: 'none', + errorBar: { visible: true }, + data: { + kind: 'points', + points: [ + { x: 0, y: 4, error: 0.5 }, + { x: 1, y: 5, error: 0.5 }, + ], + }, + }, + ], + }) + + expect(wrapper.find('.waveform-chart__line[data-series-id="triangle-errors"]').exists()).toBe( + false, + ) + const stepLine = wrapper.get('.waveform-chart__line[data-series-id="step-errors"]') + expect(stepLine.attributes('data-line-type')).toBe('step-after') + expect(stepLine.attributes('d')).toMatch(/^M[\d.-]+,([\d.-]+)L[\d.-]+,\1L/) + expect( + wrapper + .get('.waveform-chart__points[data-series-id="triangle-errors"]') + .attributes('data-point-type'), + ).toBe('triangle') + expect(wrapper.findAll('.waveform-chart__point')).toHaveLength(2) + expect( + wrapper + .get('.waveform-chart__points[data-series-id="triangle-errors"] .waveform-chart__point') + .attributes('d'), + ).toMatch(/M.*M/) + expect(wrapper.findAll('.waveform-chart__error-bar')).toHaveLength(3) + expect( + wrapper + .get('.waveform-chart__error-bars[data-series-id="step-errors"] .waveform-chart__error-bar') + .attributes('stroke'), + ).toBe('#222222') + const errorsOnlySeries = wrapper.get('.waveform-chart__series[data-series-id="errors-only"]') + expect(errorsOnlySeries.find('.waveform-chart__line').exists()).toBe(false) + expect(errorsOnlySeries.find('.waveform-chart__point').exists()).toBe(false) + expect(errorsOnlySeries.find('.waveform-chart__error-bar').exists()).toBe(true) + + const swatches = wrapper.findAll('.waveform-legend__swatch') + expect(swatches.map((swatch) => swatch.attributes('data-line-type'))).toEqual([ + 'none', + 'linear', + 'step-after', + 'none', + ]) + expect(swatches[0]?.attributes('data-error-bar-visible')).toBe('true') + expect(swatches[2]?.attributes('data-error-bar-visible')).toBe('true') + expect(swatches[3]?.attributes('data-error-bar-visible')).toBe('true') + expect(swatches[0]!.findAll('path').map((path) => path.classes())).toEqual([ + ['waveform-legend__error-bar'], + ['waveform-legend__point'], + ]) + const stepSwatchPaths = swatches[2]!.findAll('path') + expect(stepSwatchPaths.map((path) => path.classes())).toEqual([ + ['waveform-legend__line'], + ['waveform-legend__error-bar'], + ['waveform-legend__point'], + ]) + expect(stepSwatchPaths[0]?.attributes()).toMatchObject({ + d: 'M1 8H25', + stroke: '#389e0d', + 'stroke-width': '1.5', + }) + expect(stepSwatchPaths[1]?.attributes()).toMatchObject({ + d: 'M8 2H18M13 2V14M8 14H18', + stroke: '#222222', + 'stroke-width': '2', + 'stroke-linecap': 'butt', + }) + expect(stepSwatchPaths[2]?.attributes('transform')).toBe('translate(13 8)') + expect(swatches[3]!.findAll('path').map((path) => path.classes())).toEqual([ + ['waveform-legend__error-bar'], + ]) + + const renderedSeriesNodes = wrapper + .findAll('.waveform-chart__line, .waveform-chart__point, .waveform-chart__error-bar') + .map((node) => node.element) + const overlay = wrapper.get('.waveform-chart__overlay--independent') + const overlayWidth = Number(overlay.attributes('width')) + const overlayHeight = Number(overlay.attributes('height')) + Object.defineProperty(overlay.element, 'getBoundingClientRect', { + value: () => ({ left: 0, top: 0, width: overlayWidth, height: overlayHeight }), + }) + overlay.element.dispatchEvent( + new MouseEvent('pointermove', { + clientX: overlayWidth / 2, + clientY: overlayHeight / 2, + bubbles: true, + }), + ) + flushAnimationFrames() + await flushPromises() + expect( + wrapper + .findAll('.waveform-chart__line, .waveform-chart__point, .waveform-chart__error-bar') + .map((node) => node.element), + ).toEqual(renderedSeriesNodes) + + wrapper.unmount() + }) + + const visibilitySeries = (): WaveformData => ({ + kind: 'series', + series: [ + { + id: 'low', + trackId: 'shared-frame', + name: '低量程', + data: { + kind: 'points', + points: [ + { x: 0, y: 0 }, + { x: 1, y: 10 }, + ], + }, + }, + { + id: 'high', + trackId: 'shared-frame', + name: '高量程', + data: { + kind: 'points', + points: [ + { x: 10, y: 1000 }, + { x: 20, y: 2000 }, + ], + }, + }, + ], + }) + it('binds overlaid series to at most four value axes in multi-axis mode', async () => { const data: WaveformData = { kind: 'series', @@ -353,8 +671,10 @@ describe('WaveformChart', () => { 'TEST_CH_1', ]) expect( - legend.findAll('.waveform-legend__swatch').map((swatch) => swatch.attributes('style')), - ).toEqual(['background-color: rgb(9, 96, 189);', 'background-color: rgb(56, 158, 13);']) + legend + .findAll('.waveform-legend__swatch') + .map((swatch) => swatch.get('path').attributes('stroke')), + ).toEqual(['#0960bd', '#389e0d']) expect(wrapper.findAll('.waveform-chart__watermark').map((item) => item.text())).toEqual([ '1', '2', @@ -374,6 +694,7 @@ describe('WaveformChart', () => { bubbles: true, }), ) + flushAnimationFrames() await flushPromises() const tooltipSeries = wrapper.findAll('.waveform-chart__tooltip-series') @@ -496,6 +817,193 @@ describe('WaveformChart', () => { }) }) + it('keeps legends display-only unless interaction is explicitly enabled', async () => { + const wrapper = await mountSizedChart(visibilitySeries(), { + grid: { rowCount: 1, columnCount: 1 }, + }) + + const items = wrapper.findAll('.waveform-chart__legend-item') + expect(items).toHaveLength(2) + expect(items.every((item) => item.attributes('disabled') !== undefined)).toBe(true) + expect(wrapper.get('.waveform-legend__panel').classes()).not.toContain( + 'waveform-legend__panel--interactive', + ) + expect(wrapper.emitted('update:hidden-series-ids')).toBeUndefined() + }) + + it('toggles series, axes, tooltips, and annotations from an interactive legend', async () => { + const wrapper = await mountSizedChart(visibilitySeries(), { + annotations: [{ id: 'high-note', seriesId: 'high', x: 15, y: 1500, text: '高值' }], + grid: { rowCount: 1, columnCount: 1 }, + legend: { interactive: true }, + overlayMode: 'multi-axis', + }) + + expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(2) + expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true) + const highLegendItem = wrapper.findAll('.waveform-chart__legend-item')[1] + expect(highLegendItem.attributes('aria-pressed')).toBe('true') + + await highLegendItem.trigger('click') + await flushPromises() + + expect( + wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), + ).toEqual(['low']) + expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(1) + 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( + 'false', + ) + expect(wrapper.emitted('update:hidden-series-ids')?.at(-1)).toEqual([['high']]) + expect(wrapper.emitted('series-visibility-change')?.at(-1)).toEqual([ + { seriesId: 'high', visible: false, hiddenSeriesIds: ['high'] }, + ]) + + const overlay = wrapper.get('.waveform-chart__overlay--independent') + const overlayWidth = Number(overlay.attributes('width')) + const overlayHeight = Number(overlay.attributes('height')) + Object.defineProperty(overlay.element, 'getBoundingClientRect', { + value: () => ({ left: 0, top: 0, width: overlayWidth, height: overlayHeight }), + }) + overlay.element.dispatchEvent( + new MouseEvent('pointermove', { + clientX: overlayWidth / 2, + clientY: overlayHeight / 2, + bubbles: true, + }), + ) + flushAnimationFrames() + await flushPromises() + expect(wrapper.findAll('.waveform-chart__tooltip-series')).toHaveLength(1) + 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.find('[data-annotation-id="high-note"]').exists()).toBe(true) + expect(wrapper.emitted('series-visibility-change')?.at(-1)).toEqual([ + { seriesId: 'high', visible: true, hiddenSeriesIds: [] }, + ]) + }) + + it('waits for controlled visibility updates and preserves unknown controlled IDs', async () => { + const wrapper = await mountSizedChart(visibilitySeries(), { + grid: { rowCount: 1, columnCount: 1 }, + hiddenSeriesIds: ['high', 'temporarily-absent'], + legend: { interactive: true }, + }) + + expect( + wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), + ).toEqual(['low']) + 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']) + + await wrapper.setProps({ hiddenSeriesIds: ['low'] }) + await flushPromises() + expect( + wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), + ).toEqual(['high']) + }) + + it('retains uncontrolled visibility by stable ID and clears removed IDs', async () => { + const original = visibilitySeries() + const wrapper = await mountSizedChart(original, { + grid: { rowCount: 1, columnCount: 1 }, + legend: { interactive: true }, + }) + await wrapper.findAll('.waveform-chart__legend-item')[1].trigger('click') + + const reversed: WaveformData = { + kind: 'series', + series: [...(original.kind === 'series' ? original.series : [])].reverse(), + } + await wrapper.setProps({ data: reversed }) + await flushPromises() + expect( + wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), + ).toEqual(['low']) + + await wrapper.setProps({ + data: { + kind: 'series', + series: original.kind === 'series' ? [original.series[0]!] : [], + }, + }) + await flushPromises() + await wrapper.setProps({ data: original }) + await flushPromises() + expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(2) + }) + + it('keeps a recoverable legend and stops chart interaction when every series is hidden', async () => { + const wrapper = await mountSizedChart(visibilitySeries(), { + defaultHiddenSeriesIds: ['low', 'high'], + grid: { rowCount: 1, columnCount: 1 }, + legend: { interactive: true }, + overlayMode: 'multi-axis', + }) + + 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.get('.waveform-track__no-visible-series').text()).toBe('暂无可见曲线') + + await wrapper.findAll('.waveform-chart__legend-item')[0].trigger('click') + await flushPromises() + expect( + wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), + ).toEqual(['low']) + expect(wrapper.findAll('.waveform-chart__axis--x')).toHaveLength(1) + expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(1) + expect(wrapper.findAll('.waveform-chart__overlay--independent')).toHaveLength(1) + }) + + it('closes an annotation editor when its series is hidden from the legend', async () => { + const wrapper = await mountSizedChart(visibilitySeries(), { + grid: { rowCount: 1, columnCount: 1 }, + legend: { interactive: true }, + }) + const overlay = wrapper.get('.waveform-chart__overlay--independent') + const overlayWidth = Number(overlay.attributes('width')) + const overlayHeight = Number(overlay.attributes('height')) + Object.defineProperty(overlay.element, 'getBoundingClientRect', { + value: () => ({ left: 0, top: 0, width: overlayWidth, height: overlayHeight }), + }) + overlay.element.dispatchEvent( + new MouseEvent('contextmenu', { + clientX: overlayWidth * 0.75, + clientY: overlayHeight / 2, + bubbles: true, + }), + ) + flushAnimationFrames() + await flushPromises() + expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(true) + + const component = wrapper.vm as typeof wrapper.vm & { + annotationInteraction: { editorDraft: { value: { annotation: { seriesId: string } } | null } } + } + const draftSeriesId = component.annotationInteraction.editorDraft.value?.annotation.seriesId + const item = wrapper + .findAll('.waveform-chart__legend-item') + .find((legendItem) => + legendItem.text().includes(draftSeriesId === 'high' ? '高量程' : '低量程'), + ) + expect(item).toBeDefined() + await item!.trigger('click') + await flushPromises() + expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(false) + }) + it('renders independent cells with separate x axes and overlays', async () => { const wrapper = await mountSizedChart(gridSeries(4), { displayMode: 'independent', @@ -556,10 +1064,10 @@ describe('WaveformChart', () => { tracks[0].get('.waveform-chart__y-axis-label-bg').attributes('x'), ) - expect(labelX).toBe(-99) + expect(labelX).toBe(-103) expect(labelBackgroundX).toBe(labelX - 12) - expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(115) - expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(115) + expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(119) + expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(119) }) it('keeps a tick-only gutter when channel labels are empty', async () => { @@ -585,8 +1093,8 @@ describe('WaveformChart', () => { const secondLeft = Number(tracks[1].attributes('data-track-left')) expect(wrapper.findAll('.waveform-chart__y-axis-label')).toHaveLength(0) - expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(85) - expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(85) + expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(89) + expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(89) }) it('keeps the Y-axis label gutter stable while paging between value ranges', async () => { @@ -1020,6 +1528,7 @@ describe('WaveformChart', () => { overlay.element.dispatchEvent( new MouseEvent('pointermove', { clientX: overlayWidth / 2, clientY: 100, bubbles: true }), ) + flushAnimationFrames() await flushPromises() const tooltipTop = Number.parseFloat( @@ -1165,6 +1674,39 @@ describe('WaveformChart', () => { expect(path).toContain(',0') }) + it('bounds dense decorations by pixel spacing while keeping one SVG path per series', async () => { + const sourcePoints = Array.from({ length: 1_000 }, (_, index) => ({ + x: index, + y: Math.sin(index / 20), + error: 0.1, + })) + const wrapper = await mountSizedChart( + { + kind: 'series', + series: [ + { + id: 'dense-decorations', + name: '密集标记', + pointType: 'triangle', + errorBar: { visible: true }, + data: { kind: 'points', points: sourcePoints }, + }, + ], + }, + { rendering: { pointMinSpacing: 10, errorBarMinSpacing: 12 } }, + ) + const overlayWidth = Number(wrapper.get('.waveform-chart__overlay').attributes('width')) + const pointPaths = wrapper.findAll('.waveform-chart__point') + const errorBarPaths = wrapper.findAll('.waveform-chart__error-bar') + const pointCount = pointPaths[0]?.attributes('d')?.match(/M/g)?.length ?? 0 + const errorBarCount = (errorBarPaths[0]?.attributes('d')?.match(/M/g)?.length ?? 0) / 3 + + expect(pointPaths).toHaveLength(1) + expect(errorBarPaths).toHaveLength(1) + expect(pointCount).toBeLessThanOrEqual(Math.ceil(overlayWidth / 10) + 2) + expect(errorBarCount).toBeLessThanOrEqual(Math.ceil(overlayWidth / 12) + 2) + }) + it('renders explicit points and supports a single point', async () => { const wrapper = await mountSizedChart({ kind: 'points', points: [{ x: 3, y: 8 }] }) @@ -1199,15 +1741,73 @@ describe('WaveformChart', () => { overlay.element.dispatchEvent( new MouseEvent('pointermove', { clientX: 700, clientY: 120, bubbles: true }), ) + flushAnimationFrames() await flushPromises() expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1, y: 5 }]) expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true) expect(wrapper.get('.waveform-chart__tooltip').text()).toContain('ms: 1,000.0000') + const crosshairLines = wrapper.findAll('.waveform-chart__crosshair line') + expect(crosshairLines).toHaveLength(1) + expect(crosshairLines[0].attributes('x1')).toBe(crosshairLines[0].attributes('x2')) + expect(crosshairLines[0].attributes('y1')).toBe('0') + expect(wrapper.find('.waveform-chart__crosshair circle').exists()).toBe(false) await overlay.trigger('pointerleave') expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null]) }) + it('coalesces pointer moves per frame and cancels pending hover work', async () => { + const wrapper = await mountSizedChart( + { + kind: 'points', + points: [ + { x: 0, y: 0 }, + { x: 1, y: 5 }, + ], + }, + { grid: { rowCount: 1, columnCount: 1 } }, + ) + const overlay = wrapper.get('.waveform-chart__overlay') + const overlayWidth = Number(overlay.attributes('width')) + Object.defineProperty(overlay.element, 'getBoundingClientRect', { + value: () => ({ left: 0, top: 0, width: overlayWidth, height: 290 }), + }) + const emittedBeforeMove = wrapper.emitted('point-hover')?.length ?? 0 + + overlay.element.dispatchEvent( + new MouseEvent('pointermove', { clientX: 0, clientY: 100, bubbles: true }), + ) + overlay.element.dispatchEvent( + new MouseEvent('pointermove', { clientX: overlayWidth, clientY: 120, bubbles: true }), + ) + + expect(pendingAnimationFrameCount()).toBe(1) + expect(wrapper.emitted('point-hover')?.length ?? 0).toBe(emittedBeforeMove) + flushAnimationFrames() + await flushPromises() + expect(wrapper.emitted('point-hover')).toHaveLength(emittedBeforeMove + 1) + expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1, y: 5 }]) + + overlay.element.dispatchEvent( + new MouseEvent('pointermove', { clientX: 0, clientY: 100, bubbles: true }), + ) + expect(pendingAnimationFrameCount()).toBe(1) + await overlay.trigger('pointerleave') + const emittedAfterLeave = wrapper.emitted('point-hover')?.length ?? 0 + expect(pendingAnimationFrameCount()).toBe(0) + flushAnimationFrames() + await flushPromises() + expect(wrapper.emitted('point-hover')).toHaveLength(emittedAfterLeave) + expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null]) + + overlay.element.dispatchEvent( + new MouseEvent('pointermove', { clientX: overlayWidth, clientY: 100, bubbles: true }), + ) + expect(pendingAnimationFrameCount()).toBe(1) + wrapper.unmount() + expect(pendingAnimationFrameCount()).toBe(0) + }) + it('renders reference grid styling and an optional frame watermark', async () => { const wrapper = await mountSizedChart( { @@ -1430,6 +2030,7 @@ describe('WaveformChart', () => { cancelable: true, }), ) + flushAnimationFrames() await flushPromises() const emittedDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as @@ -1469,6 +2070,7 @@ describe('WaveformChart', () => { cancelable: true, }), ) + flushAnimationFrames() await flushPromises() const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number] @@ -1853,6 +2455,7 @@ describe('WaveformChart', () => { overlay.element.dispatchEvent( new MouseEvent('pointermove', { clientX: 700, clientY: 120, bubbles: true }), ) + flushAnimationFrames() await flushPromises() const tooltip = wrapper.get('.waveform-chart__tooltip') @@ -1860,10 +2463,34 @@ describe('WaveformChart', () => { expect(tooltip.text()).toContain('2 T') expect(tooltip.text()).toContain('BT1_2M:') expect(tooltip.text()).toContain('4 T') - expect(wrapper.findAll('.waveform-chart__crosshair circle')).toHaveLength(2) + const crosshairLines = wrapper.findAll('.waveform-chart__crosshair line') + expect(crosshairLines).toHaveLength(2) + crosshairLines.forEach((line) => { + expect(line.attributes('x1')).toBe(line.attributes('x2')) + expect(line.attributes('y1')).toBe('0') + }) + expect(wrapper.find('.waveform-chart__crosshair circle').exists()).toBe(false) expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1, y: 2 }]) }) + it('keeps synchronized hover feedback in compact mode', async () => { + const wrapper = await mountSizedChart(gridSeries(2), { displayMode: 'compact' }) + const overlay = wrapper.get('.waveform-chart__overlay--shared') + Object.defineProperty(overlay.element, 'getBoundingClientRect', { + value: () => ({ left: 0, top: 0, width: 712, height: 290 }), + }) + + overlay.element.dispatchEvent( + new MouseEvent('pointermove', { clientX: 700, clientY: 120, bubbles: true }), + ) + flushAnimationFrames() + await flushPromises() + + expect(wrapper.findAll('.waveform-chart__crosshair line')).toHaveLength(2) + expect(wrapper.findAll('.waveform-chart__tooltip-series')).toHaveLength(2) + expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1, y: 1 }]) + }) + it('keeps separated tracks apart while sharing one x-axis and one interaction layer', async () => { const wrapper = await mountSizedChart( { @@ -1998,6 +2625,7 @@ describe('WaveformChart', () => { cancelable: true, }), ) + flushAnimationFrames() await flushPromises() expect(endpoints()[0]).not.toBe(initialEndpoints[0]) @@ -2054,9 +2682,21 @@ describe('WaveformChart', () => { cancelable: true, }), ) + overlay.element.dispatchEvent( + new WheelEvent('wheel', { + deltaY: -200, + clientX: 356, + clientY: 145, + bubbles: true, + cancelable: true, + }), + ) + expect(pendingAnimationFrameCount()).toBe(1) + expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(initialZoomEventCount) + flushAnimationFrames() await flushPromises() - expect(wrapper.emitted('zoom-change')?.length ?? 0).toBeGreaterThan(initialZoomEventCount) + expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(initialZoomEventCount + 1) await wrapper.setProps({ zoomable: false }) await flushPromises() @@ -2070,6 +2710,7 @@ describe('WaveformChart', () => { cancelable: true, }), ) + flushAnimationFrames() await flushPromises() expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(zoomEventCount) @@ -2375,6 +3016,7 @@ describe('WaveformChart', () => { cancelable: true, }), ) + flushAnimationFrames() await flushPromises() expect(wrapper.get('.waveform-annotation__arrow').attributes('x2')).not.toBe(initialX) diff --git a/src/components/WaveformChart.vue b/src/components/WaveformChart.vue index c18982d..6dcf2d6 100644 --- a/src/components/WaveformChart.vue +++ b/src/components/WaveformChart.vue @@ -73,7 +73,11 @@ import { type WaveformGridOptions, } from './core/grid' import type { DisplaySeries, DisplayTrack, HoveredSeriesPoint, TrackLayout } from './core/types' -import { buildTrackLayouts, measureTrackYAxisClearance } from './core/layout' +import { + buildTrackLayouts, + measureTrackYAxisClearance, + Y_AXIS_EXPONENT_GAP, +} from './core/layout' import { calculateRotatedTitleLayout, TITLE_AREA_HORIZONTAL_PADDING } from './core/title' import { usePreparedWaveformSeries } from './core/useWaveformData' import WaveformAnnotationEditor from './annotation/WaveformAnnotationEditor.vue' @@ -101,6 +105,8 @@ const props = withDefaults( rendering?: WaveformRenderingOptions title?: WaveformTitleOptions legend?: WaveformLegendOptions + hiddenSeriesIds?: string[] + defaultHiddenSeriesIds?: string[] }>(), { displayMode: 'independent', @@ -118,6 +124,7 @@ const props = withDefaults( grid: () => ({ rowCount: 2, columnCount: 1, showPagination: true }), rendering: () => ({}), legend: () => ({ position: 'top-right', orientation: 'auto' }), + defaultHiddenSeriesIds: () => [], }, ) @@ -127,6 +134,14 @@ const emit = defineEmits<{ 'update:annotations': [annotations: WaveformAnnotation[]] 'update:annotations-visible': [visible: boolean] 'update:interaction-mode': [mode: WaveformInteractionMode] + 'update:hidden-series-ids': [ids: string[]] + 'series-visibility-change': [ + payload: { + seriesId: string + visible: boolean + hiddenSeriesIds: string[] + }, + ] 'annotation-create': [annotation: WaveformAnnotation] 'annotation-update': [annotation: WaveformAnnotation, previous: WaveformAnnotation] 'annotation-delete': [annotation: WaveformAnnotation] @@ -153,10 +168,16 @@ const resizeObserver = shallowRef() const zoomBehaviors = new Map>() const clipPathId = `${useId()}-waveform-clip` const internalInteractionMode = ref(undefined) +const internalHiddenSeriesIds = ref(new Set(props.defaultHiddenSeriesIds)) const annotationInteraction = useWaveformAnnotationInteraction() const editorSeriesOptions = ref([]) let generatedAnnotationId = 0 let synchronizingZoomTransform = false +let zoomAnimationFrame: number | null = null +let pendingSharedZoomTransform: ZoomTransform | null = null +const pendingIndependentZoomTransforms = new Map() +let hoverAnimationFrame: number | null = null +let pendingHoverUpdate: (() => void) | null = null const preparedSeries = usePreparedWaveformSeries(() => props.data, handleDataReferenceChange) // 用于传递给 WaveformTooltip 的接口 @@ -188,6 +209,13 @@ const legendPosition = computed(() => props.legend?.posi const legendBackgroundColor = computed( () => props.legend?.backgroundColor || 'rgba(255, 255, 255, 0.7)', ) +const legendInteractive = computed(() => props.legend?.interactive === true) +const hiddenSeriesIdSet = computed(() => + props.hiddenSeriesIds === undefined + ? internalHiddenSeriesIds.value + : new Set(props.hiddenSeriesIds), +) +const resolvedHiddenSeriesIds = computed(() => Array.from(hiddenSeriesIdSet.value)) const legendOrientation = computed>(() => { const orientation = props.legend?.orientation ?? 'auto' if (orientation !== 'auto') return orientation @@ -288,12 +316,16 @@ const chartTracks = computed(() => { if (trackSeries) trackSeries.push(series) else groupedSeries.set(trackId, [series]) }) - return Array.from(groupedSeries, ([id, series]) => ({ - id, - series, - xDomain: paddedDomain(series.flatMap((item) => item.xDomain)), - yDomain: paddedDomain(series.flatMap((item) => item.yDomain)), - })) + return Array.from(groupedSeries, ([id, series]) => { + const visibleSeries = series.filter((item) => !hiddenSeriesIdSet.value.has(item.id)) + return { + id, + series, + visibleSeries, + xDomain: paddedDomain(visibleSeries.flatMap((item) => item.xDomain)), + yDomain: paddedDomain(visibleSeries.flatMap((item) => item.yDomain)), + } + }) }) const gridOptions = computed(() => normalizeGridOptions(props.grid)) const renderingOptions = computed(() => resolveWaveformRenderingOptions(props.rendering)) @@ -307,19 +339,20 @@ const yAxisTickPadding = 7 const yAxisOuterPadding = 4 const yAxisLabelGap = 6 const yAxisLabelBandWidth = 24 -const yAxisExponentGap = 4 const minimumPlotWidth = 120 const yAxisMetrics = computed(() => { - const axisText = chartTracks.value.map((track) => { - const scale = scaleLinear(track.yDomain, [1, 0]).nice() - const [axisMin, axisMax] = scale.domain() - const values = scale.ticks(10) - return { - exponentLabel: formatScientificAxisExponent(axisMin, axisMax), - tickLabels: values.map((value) => formatScientificAxisLabel(value, { axisMin, axisMax })), - } - }) + const axisText = chartTracks.value + .filter((track) => track.visibleSeries.length > 0) + .map((track) => { + const scale = scaleLinear(track.yDomain, [1, 0]).nice() + const [axisMin, axisMax] = scale.domain() + const values = scale.ticks(10) + return { + exponentLabel: formatScientificAxisExponent(axisMin, axisMax), + tickLabels: values.map((value) => formatScientificAxisLabel(value, { axisMin, axisMax })), + } + }) const formattedTickLabels = axisText.flatMap(({ tickLabels }) => tickLabels) const maximumCharacterCount = Math.max(1, ...formattedTickLabels.map((label) => label.length)) const tickTextWidth = maximumCharacterCount * yAxisCharacterWidth @@ -327,7 +360,9 @@ const yAxisMetrics = computed(() => { 0, ...axisText.map(({ exponentLabel }) => (exponentLabel?.length ?? 0) * yAxisCharacterWidth), ) - const exponentClearance = maximumExponentWidth ? maximumExponentWidth + yAxisExponentGap : 0 + const exponentClearance = maximumExponentWidth + ? maximumExponentWidth + Y_AXIS_EXPONENT_GAP + : 0 const tickClearance = tickTextWidth + yAxisTickPadding + exponentClearance + yAxisOuterPadding const labelCenterX = -( yAxisTickPadding + @@ -348,15 +383,20 @@ const yAxisMetrics = computed(() => { }) const hasYAxisLabels = computed(() => chartTracks.value.some( - (track) => track.series.length === 1 && Boolean(track.series[0]?.name.trim() || props.yLabel), + (track) => + track.visibleSeries.length === 1 && + Boolean(track.visibleSeries[0]?.name.trim() || props.yLabel), ), ) +const hasVisibleWaveformData = computed(() => + chartTracks.value.some((track) => track.visibleSeries.length > 0), +) const chartLeftMargin = computed(() => Math.max( margin.left, hasYAxisLabels.value ? yAxisMetrics.value.fullClearance - : chartSeries.value.length + : hasVisibleWaveformData.value ? yAxisMetrics.value.tickClearance : 0, ), @@ -397,9 +437,9 @@ const yAxisLayout = computed(() => { return { horizontalGap: - props.overlayMode === 'multi-axis' && hasMultipleColumns && chartSeries.value.length + props.overlayMode === 'multi-axis' && hasMultipleColumns && hasVisibleWaveformData.value ? Math.max(baseGap, multiAxisClearance.value.left + multiAxisClearance.value.right) - : hasMultipleColumns && chartSeries.value.length + : hasMultipleColumns && hasVisibleWaveformData.value ? hasYAxisLabels.value && canReserveLabelClearance ? fullGap : tickGap @@ -433,7 +473,9 @@ const tooltipSeriesPoints = computed(() => { }) const sharedXDomain = computed(() => - paddedDomain(chartTracks.value.flatMap((track) => track.xDomain)), + paddedDomain( + chartTracks.value.flatMap((track) => (track.visibleSeries.length ? track.xDomain : [])), + ), ) const sharedZoomDomain = computed( () => @@ -526,27 +568,71 @@ function resolveFrameNumber(trackIndex: number): string | number | undefined { function handleSharedZoom(event: D3ZoomEvent) { if (synchronizingZoomTransform) return - const transform = event.transform - sharedTransform.value = transform - const domain = transform - .rescaleX(scaleLinear(sharedXDomain.value, [0, innerWidth.value])) - .domain() - emit('zoom-change', [domain[0], domain[1]]) + cancelPendingHover() + pendingSharedZoomTransform = event.transform + scheduleZoomCommit() } function handleIndependentZoom(event: D3ZoomEvent, trackIndex: number) { if (synchronizingZoomTransform) return - const transform = event.transform + cancelPendingHover() + pendingIndependentZoomTransforms.set(trackIndex, event.transform) + scheduleZoomCommit() +} + +function commitPendingZoom() { + if (pendingSharedZoomTransform) { + const transform = pendingSharedZoomTransform + pendingSharedZoomTransform = null + sharedTransform.value = transform + const domain = transform + .rescaleX(scaleLinear(sharedXDomain.value, [0, innerWidth.value])) + .domain() + emit('zoom-change', [domain[0], domain[1]]) + } + + if (!pendingIndependentZoomTransforms.size) return const nextTransforms = [...independentTransforms.value] - nextTransforms[trackIndex] = transform + const changedTrackIndexes = Array.from(pendingIndependentZoomTransforms.keys()) + pendingIndependentZoomTransforms.forEach((transform, trackIndex) => { + nextTransforms[trackIndex] = transform + }) + pendingIndependentZoomTransforms.clear() independentTransforms.value = nextTransforms - const track = trackLayouts.value[trackIndex] - if (!track) return - const domain = track.xScale.domain() - emit('zoom-change', [domain[0], domain[1]]) + changedTrackIndexes.forEach((trackIndex) => { + const track = trackLayouts.value.find((item) => item.index === trackIndex) + if (!track) return + const domain = track.xScale.domain() + emit('zoom-change', [domain[0], domain[1]]) + }) +} + +function scheduleZoomCommit() { + if (zoomAnimationFrame !== null) return + zoomAnimationFrame = requestAnimationFrame(() => { + zoomAnimationFrame = null + commitPendingZoom() + }) +} + +function flushPendingZoom() { + if (zoomAnimationFrame !== null) { + cancelAnimationFrame(zoomAnimationFrame) + zoomAnimationFrame = null + } + commitPendingZoom() +} + +function cancelPendingZoom() { + pendingSharedZoomTransform = null + pendingIndependentZoomTransforms.clear() + if (zoomAnimationFrame === null) return + cancelAnimationFrame(zoomAnimationFrame) + zoomAnimationFrame = null } function clearZoomBindings() { + cancelPendingZoom() const svg = svgElement.value if (svg) { const overlays = svg.querySelectorAll('.waveform-chart__overlay') @@ -579,6 +665,7 @@ function configureZoom() { [track.width, track.height], ]) .on('zoom', (event) => handleIndependentZoom(event, track.index)) + .on('end', flushPendingZoom) zoomBehaviors.set(track.index, behavior) synchronizingZoomTransform = true try { @@ -604,6 +691,7 @@ function configureZoom() { [innerWidth.value, innerHeight.value], ]) .on('zoom', handleSharedZoom) + .on('end', flushPendingZoom) zoomBehaviors.set('shared', behavior) const overlay = sharedOverlayElement.value if (overlay) { @@ -616,7 +704,51 @@ function configureZoom() { } } +function cancelPendingHover() { + pendingHoverUpdate = null + if (hoverAnimationFrame === null) return + cancelAnimationFrame(hoverAnimationFrame) + hoverAnimationFrame = null +} + +function scheduleHover(update: () => void) { + pendingHoverUpdate = update + if (hoverAnimationFrame !== null) return + hoverAnimationFrame = requestAnimationFrame(() => { + hoverAnimationFrame = null + const nextUpdate = pendingHoverUpdate + pendingHoverUpdate = null + nextUpdate?.() + }) +} + +function hoveredPointsMatch(nextPoints: HoveredSeriesPoint[]): boolean { + return ( + hoveredSeriesPoints.value.length === nextPoints.length && + nextPoints.every((point, index) => { + const current = hoveredSeriesPoints.value[index] + return ( + current?.id === point.id && + current.trackIndex === point.trackIndex && + current.point === point.point + ) + }) + ) +} + +function commitHover( + nextPoints: HoveredSeriesPoint[], + trackIndex: number | null, + position: { x: number; y: number }, +) { + if (!hoveredPointsMatch(nextPoints)) hoveredSeriesPoints.value = nextPoints + hoveredTrackIndex.value = trackIndex + hoverPosition.value = position + emit('point-hover', nextPoints[0]?.point ?? null) +} + function clearHover() { + cancelPendingHover() hoveredSeriesPoints.value = [] hoveredTrackIndex.value = null emit('point-hover', null) @@ -651,6 +783,18 @@ function setAnnotationsVisible(visible: boolean) { emit('update:annotations-visible', visible) } +function toggleSeriesVisibility(seriesId: string) { + if (!chartSeries.value.some((series) => series.id === seriesId)) return + const nextHiddenSeriesIds = new Set(hiddenSeriesIdSet.value) + const visible = nextHiddenSeriesIds.has(seriesId) + if (visible) nextHiddenSeriesIds.delete(seriesId) + else nextHiddenSeriesIds.add(seriesId) + const ids = Array.from(nextHiddenSeriesIds) + if (props.hiddenSeriesIds === undefined) internalHiddenSeriesIds.value = nextHiddenSeriesIds + emit('update:hidden-series-ids', ids) + emit('series-visibility-change', { seriesId, visible, hiddenSeriesIds: ids }) +} + function resolvePointerEditorAnchor( event: MouseEvent, trackIndex?: number, @@ -709,7 +853,9 @@ function changeDraftSeries(seriesId: string) { ) const series = track?.seriesList.find((item) => item.id === seriesId) const point = - series && draft ? interpolateAnnotationPoint(series.points, draft.annotation.x) : null + series && draft + ? interpolateAnnotationPoint(series.points, draft.annotation.x, series.lineType) + : null if (!draft || !candidate || !track || !point) return draft.annotation = { ...draft.annotation, @@ -734,8 +880,12 @@ function resolveTrackAtPointer( pointerY: number, trackIndex?: number, ): TrackLayout | undefined { - if (trackIndex !== undefined) return trackLayouts.value[trackIndex] - if (!trackLayouts.value.length) return undefined + if (trackIndex !== undefined) { + const track = trackLayouts.value[trackIndex] + return track?.hasVisibleSeries ? track : undefined + } + const visibleTracks = trackLayouts.value.filter((track) => track.hasVisibleSeries) + if (!visibleTracks.length) return undefined const distanceToTrack = (track: TrackLayout) => { const xDistance = @@ -748,7 +898,7 @@ function resolveTrackAtPointer( if (pointerY > track.top + track.height) return pointerY - (track.top + track.height) return xDistance } - return trackLayouts.value.reduce((closest, candidate) => { + return visibleTracks.reduce((closest, candidate) => { const distance = distanceToTrack(candidate) const closestDistance = distanceToTrack(closest) if (distance !== closestDistance) return distance < closestDistance ? candidate : closest @@ -899,44 +1049,49 @@ function confirmAnnotation(annotation: WaveformAnnotation) { function handleIndependentPointerMove(event: PointerEvent, trackIndex: number) { const overlay = event.currentTarget as SVGRectElement | null - const track = trackLayouts.value[trackIndex] - if (!overlay || !track) return + if (!overlay) return const [pointerX, pointerY] = pointer(event, overlay) - const xValue = track.xScale.invert(Math.max(0, Math.min(innerWidth.value, pointerX))) - hoveredSeriesPoints.value = track.seriesList.flatMap((series) => { - const point = nearestPoint(series, xValue) - return point ? [{ ...series, trackIndex, point }] : [] + scheduleHover(() => { + const track = trackLayouts.value[trackIndex] + if (!track) return + const xValue = track.xScale.invert(Math.max(0, Math.min(innerWidth.value, pointerX))) + const nextPoints = track.seriesList.flatMap((series) => { + const point = nearestPoint(series, xValue) + return point ? [{ ...series, trackIndex, point }] : [] + }) + commitHover(nextPoints, trackIndex, { + x: resolvedChartLeftMargin.value + track.left + pointerX, + y: titleAreaHeight.value + margin.top + track.top + pointerY, + }) }) - hoveredTrackIndex.value = trackIndex - hoverPosition.value = { - x: resolvedChartLeftMargin.value + track.left + pointerX, - y: titleAreaHeight.value + margin.top + track.top + pointerY, - } - emit('point-hover', hoveredSeriesPoints.value[0]?.point ?? null) } function handleSharedPointerMove(event: PointerEvent) { if (!sharedOverlayElement.value || !trackLayouts.value.length) return const [pointerX, pointerY] = pointer(event, sharedOverlayElement.value) - const referenceTrack = resolveTrackAtPointer(pointerX, pointerY) ?? trackLayouts.value[0] - if (!referenceTrack) return - const localPointerX = Math.max(0, Math.min(referenceTrack.width, pointerX - referenceTrack.left)) - const xValue = referenceTrack.xScale.invert(localPointerX) - hoveredSeriesPoints.value = trackLayouts.value.flatMap((track) => - track.seriesList.flatMap((series) => { - const point = nearestPoint(series, xValue) - return point ? [{ ...series, trackIndex: track.index, point }] : [] - }), - ) - hoveredTrackIndex.value = null - hoverPosition.value = { - x: resolvedChartLeftMargin.value + pointerX, - y: titleAreaHeight.value + margin.top + pointerY, - } - emit('point-hover', hoveredPoint.value) + scheduleHover(() => { + const referenceTrack = resolveTrackAtPointer(pointerX, pointerY) ?? trackLayouts.value[0] + if (!referenceTrack) return + const localPointerX = Math.max( + 0, + Math.min(referenceTrack.width, pointerX - referenceTrack.left), + ) + const xValue = referenceTrack.xScale.invert(localPointerX) + const nextPoints = trackLayouts.value.flatMap((track) => + track.seriesList.flatMap((series) => { + const point = nearestPoint(series, xValue) + return point ? [{ ...series, trackIndex: track.index, point }] : [] + }), + ) + commitHover(nextPoints, null, { + x: resolvedChartLeftMargin.value + pointerX, + y: titleAreaHeight.value + margin.top + pointerY, + }) + }) } function resetViewport() { + cancelPendingZoom() sharedTransform.value = zoomIdentity independentTransforms.value = chartTracks.value.map(() => zoomIdentity) clearHover() @@ -1012,6 +1167,45 @@ watch(activeInteractionMode, () => { editorSeriesOptions.value = [] }) +watch( + () => chartSeries.value.map((series) => series.id).join('\u0000'), + () => { + if (props.hiddenSeriesIds !== undefined) return + const availableIds = new Set(chartSeries.value.map((series) => series.id)) + const retainedIds = new Set( + Array.from(internalHiddenSeriesIds.value).filter((seriesId) => availableIds.has(seriesId)), + ) + if ( + retainedIds.size !== internalHiddenSeriesIds.value.size || + Array.from(retainedIds).some((seriesId) => !internalHiddenSeriesIds.value.has(seriesId)) + ) { + internalHiddenSeriesIds.value = retainedIds + } + }, + { immediate: true }, +) + +watch( + () => + chartTracks.value + .flatMap((track) => track.visibleSeries.map((series) => series.id)) + .join('\u0000'), + () => { + clearHover() + editorSeriesOptions.value = [] + const draftSeriesId = annotationInteraction.editorDraft.value?.annotation.seriesId + if (draftSeriesId && hiddenSeriesIdSet.value.has(draftSeriesId)) { + annotationInteraction.closeEditor() + } + const contextAnnotationId = annotationInteraction.contextMenu.value?.annotationId + const contextAnnotation = props.annotations.find((item) => item.id === contextAnnotationId) + if (contextAnnotation && hiddenSeriesIdSet.value.has(contextAnnotation.seriesId)) { + annotationInteraction.closeContextMenu() + } + void nextTick(configureZoom) + }, +) + watch( () => props.annotationsVisible, (visible) => { @@ -1070,6 +1264,7 @@ onMounted(() => { }) onBeforeUnmount(() => { + cancelPendingHover() resizeObserver.value?.disconnect() clearZoomBindings() editorSeriesOptions.value = [] @@ -1153,6 +1348,22 @@ onBeforeUnmount(() => { /> + + { :legend-position="legendPosition" :legend-orientation="legendOrientation" :legend-background-color="legendBackgroundColor" + :legend-interactive="legendInteractive" + :hidden-series-ids="resolvedHiddenSeriesIds" :hovered-point="hoveredSeriesPoints.find((p) => p.trackIndex === track.index)" @pointer-move="handleIndependentPointerMove($event, track.index)" @pointer-leave="clearHover" @click="handleAnnotationClick($event, track.index)" @contextmenu="handleAnnotationContextMenu($event, track.index)" - /> - - { ).toBeNull() }) + it('interpolates start, middle, and end step lines at their visual transitions', () => { + const points = [ + { x: 0, y: 2 }, + { x: 2, y: 10 }, + ] + + expect(interpolateAnnotationPoint(points, 0.5, 'step-start')).toEqual({ x: 0.5, y: 10 }) + expect(interpolateAnnotationPoint(points, 0.5, 'step-middle')).toEqual({ x: 0.5, y: 2 }) + expect(interpolateAnnotationPoint(points, 1, 'step-middle')).toEqual({ x: 1, y: 10 }) + expect(interpolateAnnotationPoint(points, 1.5, 'step-middle')).toEqual({ x: 1.5, y: 10 }) + expect(interpolateAnnotationPoint(points, 1, 'step-end')).toEqual({ x: 1, y: 2 }) + expect(interpolateAnnotationPoint(points, 1, 'step-after')).toEqual({ x: 1, y: 2 }) + expect(interpolateAnnotationPoint(points, 2, 'step-start')).toEqual({ x: 2, y: 10 }) + expect(interpolateAnnotationPoint(points, 2, 'step-after')).toEqual({ x: 2, y: 10 }) + expect(interpolateAnnotationPoint(points, 1, 'none')).toBeNull() + expect(interpolateAnnotationPoint(points, 2, 'none')).toEqual({ x: 2, y: 10 }) + }) + + it('omits interpolated candidates for point-only series between samples', () => { + const pointOnly = createTrack(0, 'points', 0, [ + { x: 0, y: 2 }, + { x: 2, y: 10 }, + ]) + pointOnly.series.lineType = 'none' + + expect(findAnnotationSeriesCandidates([pointOnly], 1, 100, 50)).toEqual([]) + }) + it('sorts line candidates by screen distance and keeps series metadata', () => { const first = createTrack(0, 'first', 0, [ { x: 0, y: 0 }, diff --git a/src/components/annotation/markup.ts b/src/components/annotation/markup.ts index ec1422c..307e92f 100644 --- a/src/components/annotation/markup.ts +++ b/src/components/annotation/markup.ts @@ -1,6 +1,6 @@ import { bisector } from 'd3' -import type { WaveformAnnotation, WaveformAnnotationStyle } from '../../types' +import type { WaveformAnnotation, WaveformAnnotationStyle, WaveformLineType } from '../../types' import type { AnnotationBoxLayout, AnnotationHit, @@ -46,6 +46,7 @@ const pointBisector = bisector((point: { x: number }) => point.x) export function interpolateAnnotationPoint( points: Array<{ x: number; y: number }>, xValue: number, + lineType: WaveformLineType = 'linear', ): { x: number; y: number } | null { if (!points.length || !Number.isFinite(xValue)) return null const first = points[0] @@ -56,8 +57,16 @@ export function interpolateAnnotationPoint( const rightIndex = pointBisector.left(points, xValue) const right = points[Math.min(rightIndex, points.length - 1)] if (right.x === xValue || rightIndex === 0) return { x: xValue, y: right.y } + if (lineType === 'none') return null const left = points[rightIndex - 1] + if (lineType === 'step-start') return { x: xValue, y: right.y } + if (lineType === 'step-middle') { + return { x: xValue, y: xValue < (left.x + right.x) / 2 ? left.y : right.y } + } + if (lineType === 'step-end' || lineType === 'step-after') { + return { x: xValue, y: left.y } + } const xSpan = right.x - left.x if (xSpan === 0) return { x: xValue, y: right.y } const ratio = (xValue - left.x) / xSpan @@ -72,7 +81,7 @@ export function findAnnotationSeriesCandidates( ): AnnotationSeriesCandidate[] { return tracks .flatMap((track): AnnotationSeriesCandidate[] => { - const point = interpolateAnnotationPoint(track.series.points, xValue) + const point = interpolateAnnotationPoint(track.series.points, xValue, track.series.lineType) if (!point) return [] const screenX = track.xScale(point.x) const screenY = track.top + track.yScale(point.y) diff --git a/src/components/annotation/types.ts b/src/components/annotation/types.ts index 812b4ad..680552b 100644 --- a/src/components/annotation/types.ts +++ b/src/components/annotation/types.ts @@ -1,6 +1,6 @@ import type { ScaleLinear } from 'd3' -import type { WaveformAnnotation, WaveformPoint } from '../../types' +import type { WaveformAnnotation, WaveformLineType, WaveformPoint } from '../../types' export interface AnnotationTrackLayout { index: number @@ -9,6 +9,7 @@ export interface AnnotationTrackLayout { name?: string color?: string unit?: string + lineType?: WaveformLineType points: WaveformPoint[] } left?: number diff --git a/src/components/core/layout.test.ts b/src/components/core/layout.test.ts index 20b6a40..8c7dd4d 100644 --- a/src/components/core/layout.test.ts +++ b/src/components/core/layout.test.ts @@ -1,13 +1,23 @@ +import { zoomIdentity } from 'd3' import { describe, expect, it } from 'vitest' +import { DEFAULT_WAVEFORM_RENDERING_OPTIONS } from '../../core' import type { DisplaySeries, DisplayTrack } from './types' -import { buildYAxisSeriesGroups, MAX_MULTI_Y_AXIS_COUNT } from './layout' +import { + buildTrackLayouts, + buildYAxisSeriesGroups, + MAX_MULTI_Y_AXIS_COUNT, + measureYAxisGroupClearance, +} from './layout' function series(id: string, minimum: number, maximum: number): DisplaySeries { return { id, name: id, color: '#1677ff', + lineType: 'linear', + pointType: 'none', + errorBar: { visible: false, width: 1.5, capWidth: 8 }, points: [ { x: 0, y: minimum }, { x: 1, y: maximum }, @@ -21,11 +31,49 @@ function track(seriesList: DisplaySeries[]): DisplayTrack { return { id: 'track', series: seriesList, + visibleSeries: seriesList, xDomain: [0, 1], yDomain: [0, 50], } } +function layoutForSeries( + sourceSeries: DisplaySeries, + rendering = DEFAULT_WAVEFORM_RENDERING_OPTIONS, + transform = zoomIdentity, +) { + const sourceTrack = track([sourceSeries]) + sourceTrack.xDomain = sourceSeries.xDomain + sourceTrack.yDomain = sourceSeries.yDomain + return buildTrackLayouts({ + cells: [ + { + slotIndex: 0, + row: 0, + column: 0, + left: 0, + top: 0, + width: 120, + height: 100, + plotHeight: 100, + cellHeight: 130, + xAxisBand: 30, + series: sourceTrack, + }, + ], + grid: { rowCount: 1, columnCount: 1, showPagination: false }, + displayMode: 'independent', + overlayMode: 'single-axis', + independentTransforms: [transform], + sharedZoomDomain: sourceSeries.xDomain, + timeUnit: 'ms', + rendering, + hideSecondaryLabels: false, + yAxisLabelX: -50, + showCompactEmptyTracks: false, + })[0]!.seriesPaths[0]! +} + describe('multi-value Y-axis grouping', () => { it('keeps every overlaid series on one axis in single-axis mode', () => { const groups = buildYAxisSeriesGroups( @@ -95,4 +143,120 @@ describe('multi-value Y-axis grouping', () => { 'right', ]) }) + + it('places left and right scientific exponents eight pixels outside their tick labels', () => { + const layout = buildTrackLayouts({ + cells: [ + { + slotIndex: 0, + row: 0, + column: 0, + left: 0, + top: 0, + width: 600, + height: 300, + plotHeight: 300, + cellHeight: 330, + xAxisBand: 30, + series: track([series('left', 0, 254), series('right', 0, 254)]), + }, + ], + grid: { rowCount: 1, columnCount: 1, showPagination: false }, + displayMode: 'independent', + overlayMode: 'multi-axis', + independentTransforms: [zoomIdentity], + sharedZoomDomain: [0, 1], + timeUnit: 'ms', + rendering: DEFAULT_WAVEFORM_RENDERING_OPTIONS, + hideSecondaryLabels: false, + yAxisLabelX: -50, + showCompactEmptyTracks: false, + })[0] + + expect( + layout?.yAxes.map(({ side, x, exponentX, exponentLabel }) => ({ + side, + offset: Math.abs(exponentX - x), + exponentLabel, + })), + ).toEqual([ + { side: 'left', offset: 43, exponentLabel: 'E+02' }, + { side: 'right', offset: 43, exponentLabel: 'E+02' }, + ]) + }) + + it('retains enough outer clearance for long scientific exponents', () => { + const [group] = buildYAxisSeriesGroups(track([series('long', -1e120, 1e120)]), 'multi-axis') + + expect(group).toBeDefined() + expect(measureYAxisGroupClearance(group!)).toBe(119) + }) +}) + +describe('decoration sampling', () => { + const denseSeries = (): DisplaySeries => ({ + ...series('dense', -1, 1), + pointType: 'circle', + errorBar: { visible: true, width: 1.5, capWidth: 8 }, + points: Array.from({ length: 1_000 }, (_, index) => ({ + x: index, + y: Math.sin(index / 20), + error: index % 200 === 1 ? 0.1 : 0, + })), + xDomain: [0, 999], + }) + + it('shares prioritized source points between dense symbols and error bars', () => { + const sourceSeries = denseSeries() + const path = layoutForSeries(sourceSeries, { + ...DEFAULT_WAVEFORM_RENDERING_OPTIONS, + pointMinSpacing: 10, + errorBarMinSpacing: 12, + }) + const sourceErrorPoints = sourceSeries.points.filter((point) => point.error !== 0) + + expect(path.errorBarRenderPoints).toEqual(sourceErrorPoints) + expect(path.errorBarRenderPoints.every((point) => path.pointRenderPoints.includes(point))).toBe( + true, + ) + expect(path.pointRenderPoints.length).toBeLessThanOrEqual(Math.ceil(120 / 12) + 2) + }) + + it('keeps standalone, zero-error, and non-downsampled decoration behavior', () => { + const noErrors = denseSeries() + noErrors.points = noErrors.points.map((point) => ({ x: point.x, y: point.y })) + const zeroErrorPath = layoutForSeries(noErrors) + expect(zeroErrorPath.errorBarRenderPoints).toEqual([]) + expect(zeroErrorPath.pointRenderPoints.length).toBeLessThanOrEqual(Math.ceil(120 / 10) + 2) + + const errorsOnly = denseSeries() + errorsOnly.pointType = 'none' + const errorsOnlyPath = layoutForSeries(errorsOnly) + expect(errorsOnlyPath.pointRenderPoints).toEqual([]) + expect(errorsOnlyPath.errorBarRenderPoints.length).toBeLessThanOrEqual(Math.ceil(120 / 12) + 2) + + const pointsOnly = denseSeries() + pointsOnly.errorBar.visible = false + const pointsOnlyPath = layoutForSeries(pointsOnly) + expect(pointsOnlyPath.errorBarRenderPoints).toEqual([]) + expect(pointsOnlyPath.pointRenderPoints.length).toBeLessThanOrEqual(Math.ceil(120 / 10) + 2) + + const completePath = layoutForSeries(denseSeries(), { + ...DEFAULT_WAVEFORM_RENDERING_OPTIONS, + downsample: false, + }) + expect(completePath.pointRenderPoints).toHaveLength(1_000) + expect(completePath.errorBarRenderPoints).toHaveLength(5) + }) + + it('restores every visible source decoration after zooming to sparse spacing', () => { + const path = layoutForSeries( + denseSeries(), + DEFAULT_WAVEFORM_RENDERING_OPTIONS, + zoomIdentity.scale(200), + ) + + expect(path.pointRenderPoints.map((point) => point.x)).toEqual([0, 1, 2, 3, 4]) + expect(path.errorBarRenderPoints.map((point) => point.x)).toEqual([1]) + }) }) diff --git a/src/components/core/layout.ts b/src/components/core/layout.ts index 54ccdbb..3d8ece5 100644 --- a/src/components/core/layout.ts +++ b/src/components/core/layout.ts @@ -1,6 +1,19 @@ -import { line, scaleLinear, zoomIdentity, type ZoomTransform } from 'd3' +import { + curveStep, + curveStepAfter, + curveStepBefore, + line, + scaleLinear, + zoomIdentity, + type ZoomTransform, +} from 'd3' -import { selectRenderablePoints, type ResolvedWaveformRenderingOptions } from '../../core' +import { + selectDecorationPoints, + selectRenderablePoints, + resolveWaveformPointErrors, + type ResolvedWaveformRenderingOptions, +} from '../../core' import type { WaveformDisplayMode, WaveformOverlayMode, WaveformPoint } from '../../types' import { buildMinorTicks, @@ -23,7 +36,7 @@ const Y_AXIS_TICK_PADDING = 7 const Y_AXIS_OUTER_PADDING = 4 const Y_AXIS_LABEL_GAP = 6 const Y_AXIS_LABEL_BAND_WIDTH = 24 -const Y_AXIS_EXPONENT_GAP = 4 +export const Y_AXIS_EXPONENT_GAP = 8 interface YAxisSeriesGroup { index: number @@ -57,8 +70,8 @@ export function buildYAxisSeriesGroups( if (cached) return cached const axisCount = overlayMode === 'multi-axis' - ? Math.min(track.series.length, MAX_MULTI_Y_AXIS_COUNT) - : Math.min(track.series.length, 1) + ? Math.min(track.visibleSeries.length, MAX_MULTI_Y_AXIS_COUNT) + : Math.min(track.visibleSeries.length, 1) const sides = resolveAxisSides(axisCount) const grouped = Array.from({ length: axisCount }, (_, index) => ({ index, @@ -67,7 +80,7 @@ export function buildYAxisSeriesGroups( domain: [0, 1] as [number, number], })) - track.series.forEach((series, index) => { + track.visibleSeries.forEach((series, index) => { grouped[Math.min(index, axisCount - 1)]?.seriesList.push(series) }) grouped.forEach((group) => { @@ -136,7 +149,7 @@ export function measureTrackYAxisClearance( return buildYAxisSeriesGroups(track, overlayMode).reduce( (clearance, group) => { clearance[group.side] += - overlayMode === 'multi-axis' || track.series.length === 1 + overlayMode === 'multi-axis' || track.visibleSeries.length === 1 ? measureYAxisGroupClearance(group) : measureYAxisGroupTickClearance(group) return clearance @@ -174,6 +187,9 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou id: `empty-grid-slot-${cell.slotIndex}`, name: '', color: 'transparent', + lineType: 'linear', + pointType: 'none', + errorBar: { visible: false, width: 1.5, capWidth: 8 }, points: [], xDomain: [0, 1], yDomain: [0, 1], @@ -181,10 +197,12 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou const displayTrack: DisplayTrack = cell.series ?? { id: emptySeries.id, series: [emptySeries], + visibleSeries: [emptySeries], xDomain: emptySeries.xDomain, yDomain: emptySeries.yDomain, } - const series = displayTrack.series[0] + const hasVisibleSeries = !isEmpty && displayTrack.visibleSeries.length > 0 + const series = displayTrack.visibleSeries[0] ?? displayTrack.series[0] ?? emptySeries const baseXScale = options.displayMode === 'independent' ? scaleLinear(displayTrack.xDomain, [0, cell.width]) @@ -220,8 +238,8 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou const exponentX = x + (group.side === 'left' - ? -(Y_AXIS_TICK_PADDING + tickTextWidth + exponentClearance) - : Y_AXIS_TICK_PADDING + tickTextWidth + exponentClearance) + ? -(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 + @@ -261,24 +279,69 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou const position = xScale(tick) return position > leftClearance && position < cell.width - rightClearance }) - const seriesPaths = displayTrack.series.map((trackSeries) => { + const seriesPaths = displayTrack.visibleSeries.map((trackSeries) => { const yAxis = yAxes.find((axis) => axis.seriesList.some((series) => series.id === trackSeries.id), ) const seriesYScale = yAxis?.scale ?? yScale - const renderPoints = selectRenderablePoints( + const pathPoints = selectRenderablePoints( trackSeries.points, domain, cell.width, options.rendering, ) + const hasError = (point: WaveformPoint) => { + const { lower, upper } = resolveWaveformPointErrors(point) + return lower !== 0 || upper !== 0 + } + const hasErrorPoints = trackSeries.errorBar.visible && trackSeries.points.some(hasError) + const sharesDecorationPoints = trackSeries.pointType !== 'none' && hasErrorPoints + const sharedDecorationPoints = sharesDecorationPoints + ? selectDecorationPoints( + trackSeries.points, + domain, + cell.width, + Math.max(options.rendering.pointMinSpacing, options.rendering.errorBarMinSpacing), + options.rendering.downsample, + undefined, + hasError, + ) + : undefined + const pointRenderPoints = + trackSeries.pointType === 'none' + ? [] + : (sharedDecorationPoints ?? + selectDecorationPoints( + trackSeries.points, + domain, + cell.width, + options.rendering.pointMinSpacing, + options.rendering.downsample, + )) + const errorBarRenderPoints = trackSeries.errorBar.visible + ? (sharedDecorationPoints?.filter(hasError) ?? + selectDecorationPoints( + trackSeries.points, + domain, + cell.width, + options.rendering.errorBarMinSpacing, + options.rendering.downsample, + hasError, + )) + : [] + const pathGenerator = line() + .x((point) => xScale(point.x)) + .y((point) => seriesYScale(point.y)) + if (trackSeries.lineType === 'step-start') pathGenerator.curve(curveStepBefore) + if (trackSeries.lineType === 'step-middle') pathGenerator.curve(curveStep) + if (trackSeries.lineType === 'step-end' || trackSeries.lineType === 'step-after') { + pathGenerator.curve(curveStepAfter) + } return { series: trackSeries, - path: isEmpty - ? null - : line() - .x((point) => xScale(point.x)) - .y((point) => seriesYScale(point.y))(renderPoints), + path: isEmpty || trackSeries.lineType === 'none' ? null : pathGenerator(pathPoints), + pointRenderPoints, + errorBarRenderPoints, yScale: seriesYScale, yAxisIndex: yAxis?.index ?? 0, } @@ -287,8 +350,10 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou return { index, series, - seriesList: displayTrack.series, + seriesList: displayTrack.visibleSeries, + legendSeries: displayTrack.series, isEmpty, + hasVisibleSeries, column: cell.column, showYAxisLabel: !options.hideSecondaryLabels || cell.column === 0, yAxisLabelX: options.yAxisLabelX, @@ -310,10 +375,11 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou path: seriesPaths[0]?.path ?? null, seriesPaths, showXAxis: - options.displayMode === 'independent' || - (options.displayMode === 'compact' - ? cell.row === options.grid.rowCount - 1 - : bottomCells.has(cell.slotIndex)), + (isEmpty || hasVisibleSeries) && + (options.displayMode === 'independent' || + (options.displayMode === 'compact' + ? cell.row === options.grid.rowCount - 1 + : bottomCells.has(cell.slotIndex))), } }) } diff --git a/src/components/core/types.ts b/src/components/core/types.ts index d9d6457..2942b5a 100644 --- a/src/components/core/types.ts +++ b/src/components/core/types.ts @@ -1,5 +1,10 @@ import type { ScaleLinear } from 'd3' -import type { WaveformPoint } from '../../types' +import type { + ResolvedWaveformErrorBarOptions, + WaveformLineType, + WaveformPoint, + WaveformPointType, +} from '../../types' /** * 显示系列 @@ -10,6 +15,9 @@ export interface DisplaySeries { name: string unit?: string color: string + lineType: WaveformLineType + pointType: WaveformPointType + errorBar: ResolvedWaveformErrorBarOptions points: WaveformPoint[] xDomain: [number, number] yDomain: [number, number] @@ -17,7 +25,10 @@ export interface DisplaySeries { export interface DisplayTrack { id: string + /** Complete series list retained for legend rendering and visibility restoration. */ series: DisplaySeries[] + /** Series currently participating in layout, rendering, and interaction. */ + visibleSeries: DisplaySeries[] xDomain: [number, number] yDomain: [number, number] } @@ -25,6 +36,8 @@ export interface DisplayTrack { export interface TrackSeriesPath { series: DisplaySeries path: string | null + pointRenderPoints: WaveformPoint[] + errorBarRenderPoints: WaveformPoint[] yScale: ScaleLinear yAxisIndex: number } @@ -57,8 +70,12 @@ export interface HoveredSeriesPoint extends DisplaySeries { export interface TrackLayout { index: number series: DisplaySeries + /** Visible series used by rendering and interaction code. */ seriesList: DisplaySeries[] + /** Complete series list used by the legend. */ + legendSeries: DisplaySeries[] isEmpty: boolean + hasVisibleSeries: boolean column: number showYAxisLabel: boolean yAxisLabelX: number diff --git a/src/components/core/useWaveformData.ts b/src/components/core/useWaveformData.ts index 931536f..ef7f0d6 100644 --- a/src/components/core/useWaveformData.ts +++ b/src/components/core/useWaveformData.ts @@ -1,7 +1,13 @@ import { shallowRef, watch } from 'vue' -import { normalizeWaveformSeries } from '../../core' -import type { WaveformData, WaveformPoint } from '../../types' +import { normalizeWaveformSeries, resolveWaveformPointErrors } from '../../core' +import type { + ResolvedWaveformErrorBarOptions, + WaveformData, + WaveformLineType, + WaveformPoint, + WaveformPointType, +} from '../../types' import { paddedDomain } from '../../utils' export interface PreparedWaveformSeries { @@ -10,18 +16,30 @@ export interface PreparedWaveformSeries { name: string unit?: string color?: string + lineType: WaveformLineType + pointType: WaveformPointType + errorBar: ResolvedWaveformErrorBarOptions points: WaveformPoint[] xDomain: [number, number] yDomain: [number, number] } -function pointDomain(points: WaveformPoint[], key: 'x' | 'y'): [number, number] { +function pointDomain( + points: WaveformPoint[], + key: 'x' | 'y', + includeErrors = false, +): [number, number] { let minimum = Number.POSITIVE_INFINITY let maximum = Number.NEGATIVE_INFINITY points.forEach((point) => { const value = point[key] if (value < minimum) minimum = value if (value > maximum) maximum = value + if (key === 'y' && includeErrors) { + const errors = resolveWaveformPointErrors(point) + minimum = Math.min(minimum, point.y - errors.lower) + maximum = Math.max(maximum, point.y + errors.upper) + } }) return paddedDomain(Number.isFinite(minimum) ? [minimum, maximum] : []) } @@ -30,7 +48,7 @@ export function prepareWaveformSeries(data: WaveformData): PreparedWaveformSerie return normalizeWaveformSeries(data).map((series) => ({ ...series, xDomain: pointDomain(series.points, 'x'), - yDomain: pointDomain(series.points, 'y'), + yDomain: pointDomain(series.points, 'y', series.errorBar.visible), })) } diff --git a/src/components/data/types.ts b/src/components/data/types.ts index 9635a04..3796611 100644 --- a/src/components/data/types.ts +++ b/src/components/data/types.ts @@ -19,6 +19,10 @@ export type { WaveformLegendOptions, WaveformFrameStyle, SingleWaveformData, + WaveformLineType, + WaveformPointType, + WaveformErrorBarOptions, + ResolvedWaveformErrorBarOptions, WaveformSeries, WaveformData, NormalizedWaveformSeries, diff --git a/src/components/index.ts b/src/components/index.ts index 7f715a2..790c22f 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -18,6 +18,9 @@ export type { WaveformFrameStyle, WaveformPoint, WaveformSeries, + WaveformLineType, + WaveformPointType, + WaveformErrorBarOptions, WaveformGridOptions, } from './data/types' diff --git a/src/components/interaction/WaveformTooltip.test.ts b/src/components/interaction/WaveformTooltip.test.ts new file mode 100644 index 0000000..4ff12f5 --- /dev/null +++ b/src/components/interaction/WaveformTooltip.test.ts @@ -0,0 +1,85 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' + +import WaveformTooltip from './WaveformTooltip.vue' + +describe('WaveformTooltip', () => { + const point = { x: 1, y: 12 } + + function mountTooltip(positionX: number, containerWidth = 400) { + return mount(WaveformTooltip, { + props: { + visible: true, + position: { x: positionX, y: 100 }, + timeUnit: 's', + hoveredPoint: point, + seriesPoints: [{ trackIndex: 0, name: 'Temperature', color: '#f00', point }], + containerWidth, + containerHeight: 300, + }, + }) + } + + it('positions the tooltip to the right when enough space remains', () => { + const tooltip = mountTooltip(100).get('.waveform-tooltip') + + expect(tooltip.attributes('style')).toContain('left: 112px') + expect(tooltip.attributes('style')).not.toContain('right:') + }) + + it('flips the tooltip to the left near the right boundary', () => { + const tooltip = mountTooltip(370).get('.waveform-tooltip') + + expect(tooltip.attributes('style')).toContain('right: 42px') + expect(tooltip.attributes('style')).not.toContain('left:') + }) + + it('keeps the tooltip inside the left boundary when neither side has enough space', () => { + const tooltip = mountTooltip(100, 200).get('.waveform-tooltip') + + expect(tooltip.attributes('style')).toContain('left: 8px') + expect(tooltip.attributes('style')).not.toContain('right:') + }) + + it('shows resolved asymmetric errors beside the hovered value', () => { + const pointWithErrors = { x: 1, y: 12, error: 1, upperError: 2 } + const wrapper = mount(WaveformTooltip, { + props: { + visible: true, + position: { x: 10, y: 10 }, + timeUnit: 's', + hoveredPoint: pointWithErrors, + seriesPoints: [ + { + trackIndex: 0, + name: '温度', + color: '#f00', + unit: 'C', + point: pointWithErrors, + }, + ], + containerWidth: 400, + containerHeight: 300, + }, + }) + + expect(wrapper.get('.waveform-tooltip__series small').text()).toBe('(+2 / -1)') + }) + + it('omits the error label when both resolved errors are zero', () => { + const point = { x: 1, y: 12 } + const wrapper = mount(WaveformTooltip, { + props: { + visible: true, + position: { x: 10, y: 10 }, + timeUnit: 's', + hoveredPoint: point, + seriesPoints: [{ trackIndex: 0, name: '温度', color: '#f00', point }], + containerWidth: 400, + containerHeight: 300, + }, + }) + + expect(wrapper.find('.waveform-tooltip__series small').exists()).toBe(false) + }) +}) diff --git a/src/components/interaction/WaveformTooltip.vue b/src/components/interaction/WaveformTooltip.vue index e8a78f2..50062f0 100644 --- a/src/components/interaction/WaveformTooltip.vue +++ b/src/components/interaction/WaveformTooltip.vue @@ -1,5 +1,6 @@