fix(chart): scope independent viewport resets

This commit is contained in:
李启源
2026-08-11 15:29:32 +08:00
parent d667350219
commit 3d6c5156c0
9 changed files with 144 additions and 69 deletions

View File

@@ -253,7 +253,9 @@ X 轴且包含多个轨道时使用按稳定 track ID 索引的 `yRanges`。平
外部重置按钮也可以通过模板引用调用组件公开的 `resetViewport()` 方法,然后执行相同的数据恢复逻辑。
独立坐标模式下,回填响应应只替换 `seriesIds` 对应的系列,并调用
`resetViewport(trackIndex)`;其他图框的数据和缩放状态应保持不变。
`resetViewport(trackIndex)`;其他图框的数据和缩放状态应保持不变。独立模式下双击图框触发的
`zoom-reset` payload 会包含该图框的 `trackIndex``seriesIds`;共享 X 范围模式的 payload
不包含这两个字段,表示全局复位。忽略事件参数的既有监听器可以继续使用。
多通道数据应为每个 `WaveformSeries` 提供稳定的 `id`。内部时间坐标始终使用秒,
`timeUnit` 只控制坐标轴和 tooltip 的显示单位。
@@ -639,7 +641,7 @@ X 轴刻度和左右端点先按 `timeUnit` 转换为秒或毫秒,再显示为
| `point-hover` | 当前最近点变化时触发,离开图表时传入 `null` |
| `zoom-change` | 缩放过程中触发,参数为 `[start, end]` |
| `zoom-end` | 滚轮或框选结束后触发;`gesture` 区分二者,独立模式附带轨道信息 |
| `zoom-reset` | 双击重置视口时触发,调用方应恢复首次完整数据 |
| `zoom-reset` | 双击重置视口时触发;独立模式 payload 标识目标图框 |
| `page-change` | 分页变化,参数为当前页和总页数 |
| `series-visibility-change` | 图例切换曲线显隐时触发 |
| `annotation-create` / `annotation-update` / `annotation-delete` | 标注新增、更新或删除 |

View File

@@ -13,6 +13,7 @@ import type {
WaveformTitleOptions,
WaveformZeroLineOptions,
WaveformZoomEndPayload,
WaveformZoomResetPayload,
} from '../data/types'
import type { WaveformGridOptions } from './grid'
@@ -82,7 +83,7 @@ export interface WaveformChartEmit {
(event: 'point-hover', point: WaveformPoint | null): void
(event: 'zoom-change', domain: [number, number]): void
(event: 'zoom-end', payload: WaveformZoomEndPayload): void
(event: 'zoom-reset'): void
(event: 'zoom-reset', payload: WaveformZoomResetPayload): void
(event: 'update:annotations', annotations: WaveformAnnotation[]): void
(event: 'update:hidden-series-ids', ids: string[]): void
(

View File

@@ -10,6 +10,7 @@ export type {
WaveformOverlayMode,
WaveformInteractionMode,
WaveformZoomEndPayload,
WaveformZoomResetPayload,
WaveformAnnotationStyle,
WaveformAnnotation,
WaveformRenderingOptions,

View File

@@ -346,9 +346,23 @@ export function useWaveformViewport(context: ViewportContext) {
}
const requestViewportReset = (event: MouseEvent) => {
if (isPresentationMode.value || !props.zoomable || !isZoomMode.value) return
if (props.displayMode === 'independent') {
const target = event.target instanceof Element ? event.target : null
const overlay = target?.closest('[data-independent-overlay-index]')
const trackIndex = Number(overlay?.getAttribute('data-independent-overlay-index'))
const track = trackLayouts.value.find((item) => item.index === trackIndex)
if (!track) return
event.preventDefault()
resetViewport(trackIndex)
emit('zoom-reset', {
trackIndex,
seriesIds: track.legendSeries.map((series) => series.id),
})
return
}
event.preventDefault()
resetViewport()
emit('zoom-reset')
emit('zoom-reset', {})
}
return {
selectionBox,

View File

@@ -308,69 +308,4 @@ describe('WaveformChart', () => {
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount + 1)
expect(zoomedOutDomain).toEqual([0, 2])
})
it('resets a shared viewport on double-click and emits zoom-reset', async () => {
const wrapper = await mountSizedChart({
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 2, y: 1 },
],
})
const overlay = wrapper.get('.waveform-chart__overlay')
const width = Number(overlay.attributes('width'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height: 290 }),
})
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -4000,
clientX: width / 2,
clientY: 145,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe('0')
overlay.element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
await flushPromises()
expect(wrapper.emitted('zoom-reset')).toHaveLength(1)
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
})
it('exposes resetViewport for independent tracks', async () => {
const wrapper = await mountSizedChart(gridSeries(2), {
displayMode: 'independent',
grid: { rowCount: 1, columnCount: 2 },
})
const overlay = wrapper.findAll('.waveform-chart__overlay--independent')[0]
const width = Number(overlay.attributes('width'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height: 260 }),
})
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -4000,
clientX: width / 2,
clientY: 130,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).not.toBe('0')
const chart = wrapper.vm as unknown as { resetViewport: () => void }
chart.resetViewport()
await flushPromises()
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0')
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1000')
})
})

View File

@@ -0,0 +1,114 @@
import { flushPromises } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { flushAnimationFrames } from '../../test/setup'
import { gridSeries, mountSizedChart } from '../../test/waveformChart'
describe('WaveformChart viewport reset', () => {
it('resets a shared viewport on double-click and emits a global payload', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 2, y: 1 },
],
},
{ displayMode: 'separated' },
)
const overlay = wrapper.get('.waveform-chart__overlay')
const width = Number(overlay.attributes('width'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height: 290 }),
})
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -4000,
clientX: width / 2,
clientY: 145,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe('0')
overlay.element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
await flushPromises()
expect(wrapper.emitted('zoom-reset')).toEqual([[{}]])
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
})
it('resets only the double-clicked independent track and identifies it', async () => {
const wrapper = await mountSizedChart(gridSeries(2), {
displayMode: 'independent',
grid: { rowCount: 1, columnCount: 2 },
})
const overlays = wrapper.findAll('.waveform-chart__overlay--independent')
for (const overlay of overlays) {
const width = Number(overlay.attributes('width'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height: 260 }),
})
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -4000,
clientX: width / 2,
clientY: 130,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
}
const startsBeforeReset = wrapper.findAll('.waveform-chart__axis-endpoint--start')
expect(startsBeforeReset[0].text()).not.toBe('0')
expect(startsBeforeReset[1].text()).not.toBe('0')
const secondTrackStart = startsBeforeReset[1].text()
overlays[0].element.dispatchEvent(
new MouseEvent('dblclick', { bubbles: true, cancelable: true }),
)
await flushPromises()
const startsAfterReset = wrapper.findAll('.waveform-chart__axis-endpoint--start')
expect(startsAfterReset[0].text()).toBe('0')
expect(startsAfterReset[1].text()).toBe(secondTrackStart)
expect(wrapper.emitted('zoom-reset')).toEqual([[{ trackIndex: 0, seriesIds: ['channel-0'] }]])
})
it('keeps the exposed no-argument resetViewport global', async () => {
const wrapper = await mountSizedChart(gridSeries(2), {
displayMode: 'independent',
grid: { rowCount: 1, columnCount: 2 },
})
const overlay = wrapper.findAll('.waveform-chart__overlay--independent')[0]
const width = Number(overlay.attributes('width'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height: 260 }),
})
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -4000,
clientX: width / 2,
clientY: 130,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).not.toBe('0')
const chart = wrapper.vm as unknown as { resetViewport: () => void }
chart.resetViewport()
await flushPromises()
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0')
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1000')
})
})

View File

@@ -14,6 +14,7 @@ export type {
WaveformOverlayMode,
WaveformInteractionMode,
WaveformZoomEndPayload,
WaveformZoomResetPayload,
WaveformAnnotationStyle,
WaveformAnnotation,
WaveformRenderingOptions,

View File

@@ -38,6 +38,12 @@ export interface WaveformZoomEndPayload {
gesture?: 'wheel' | 'box'
}
/** Identifies the viewport reset by a double-click gesture. */
export interface WaveformZoomResetPayload {
trackIndex?: number
seriesIds?: string[]
}
/** 标注颜色样式 */
export interface WaveformAnnotationStyle {
borderColor?: string

View File

@@ -9,6 +9,7 @@ export type {
WaveformOverlayMode,
WaveformInteractionMode,
WaveformZoomEndPayload,
WaveformZoomResetPayload,
WaveformAnnotationStyle,
WaveformAnnotation,
WaveformRenderingOptions,