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 @@
@@ -60,6 +80,7 @@ const tooltipStyle = computed(() => {
{{ formatTooltipNumber(seriesPoint.point.y)
}}{{ seriesPoint.unit ? ` ${seriesPoint.unit}` : '' }}
+ {{ formatError(seriesPoint.point) }}
@@ -67,6 +88,7 @@ const tooltipStyle = computed(() => {
diff --git a/src/components/rendering/WaveformLegend.vue b/src/components/rendering/WaveformLegend.vue
index 8259ba8..5496015 100644
--- a/src/components/rendering/WaveformLegend.vue
+++ b/src/components/rendering/WaveformLegend.vue
@@ -1,6 +1,13 @@
@@ -32,19 +56,62 @@ defineProps()
>
@@ -119,6 +186,10 @@ defineProps()
border-radius: 4px;
}
+.waveform-legend__panel--interactive {
+ pointer-events: auto;
+}
+
.waveform-legend__panel--horizontal {
flex-flow: row wrap;
align-items: center;
@@ -135,13 +206,44 @@ defineProps()
max-width: 160px;
align-items: center;
gap: 6px;
+ padding: 0;
+ color: inherit;
+ font: inherit;
+ text-align: left;
white-space: nowrap;
+ appearance: none;
+ background: none;
+ border: 0;
+}
+
+.waveform-legend__item:disabled {
+ opacity: 1;
+}
+
+.waveform-legend__panel--interactive .waveform-legend__item {
+ cursor: pointer;
+}
+
+.waveform-legend__panel--interactive .waveform-legend__item:focus-visible {
+ outline: 2px solid #1677ff;
+ outline-offset: 2px;
+}
+
+.waveform-legend__item.is-hidden {
+ opacity: 0.45;
+}
+
+.waveform-legend__item.is-hidden .waveform-legend__label {
+ text-decoration: line-through;
}
.waveform-legend__swatch {
- flex: 0 0 18px;
- width: 18px;
- height: 2px;
+ flex: 0 0 26px;
+ width: 26px;
+ height: 16px;
+ overflow: visible;
+ stroke-linecap: round;
+ stroke-linejoin: round;
}
.waveform-legend__label {
diff --git a/src/components/rendering/WaveformSeriesLayer.vue b/src/components/rendering/WaveformSeriesLayer.vue
new file mode 100644
index 0000000..b8f5432
--- /dev/null
+++ b/src/components/rendering/WaveformSeriesLayer.vue
@@ -0,0 +1,117 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/rendering/WaveformTrack.vue b/src/components/rendering/WaveformTrack.vue
index ef72cfd..1191107 100644
--- a/src/components/rendering/WaveformTrack.vue
+++ b/src/components/rendering/WaveformTrack.vue
@@ -15,6 +15,7 @@ import type {
WaveformYAxisLayout,
} from '../core/types'
import WaveformLegend from './WaveformLegend.vue'
+import WaveformSeriesLayer from './WaveformSeriesLayer.vue'
interface Props {
/** 轨道布局信息 */
@@ -47,6 +48,10 @@ interface Props {
legendOrientation?: 'horizontal' | 'vertical'
/** 多曲线图例背景颜色 */
legendBackgroundColor?: string
+ /** 图例是否允许切换曲线显隐 */
+ legendInteractive?: boolean
+ /** 当前隐藏的系列 ID */
+ hiddenSeriesIds?: string[]
}
interface Emits {
@@ -54,6 +59,7 @@ interface Emits {
(e: 'pointer-leave'): void
(e: 'click', event: MouseEvent): void
(e: 'contextmenu', event: MouseEvent): void
+ (e: 'series-visibility-toggle', seriesId: string): void
}
const props = withDefaults(defineProps(), {
@@ -61,6 +67,8 @@ const props = withDefaults(defineProps(), {
legendPosition: 'top-right',
legendOrientation: 'vertical',
legendBackgroundColor: 'rgba(255, 255, 255, 0.7)',
+ legendInteractive: false,
+ hiddenSeriesIds: () => [],
})
const emit = defineEmits()
@@ -92,14 +100,6 @@ function setYAxisElement(element: unknown, index: number) {
if (element) yAxisElements.value[index] = element as SVGGElement
}
-function resolveHoveredYScale() {
- const seriesId = props.hoveredPoint?.id
- return (
- props.track.seriesPaths.find((seriesPath) => seriesPath.series.id === seriesId)?.yScale ??
- props.track.yScale
- )
-}
-
/**
* 判断是否应该显示 Y 轴标签
* 在紧凑模式下,当轨道高度太小时隐藏标签避免重叠
@@ -121,12 +121,6 @@ function crosshairX(): number {
: 0
}
-function crosshairY(): number {
- return props.hoveredPoint && props.hoveredPoint.trackIndex === props.track.index
- ? resolveHoveredYScale()(props.hoveredPoint.point.y)
- : 0
-}
-
function hasCrosshair(): boolean {
return (
props.showTooltip &&
@@ -213,7 +207,11 @@ watch(
/>
-
+
@@ -258,7 +256,7 @@ watch(
-
-
-
-
-
-
+
+
-
-
+
+
+ 暂无可见曲线
+
+
+