From 3d6c5156c0c05dfbe68bf53d01455671a8ec2667 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=90=AF=E6=BA=90?= Date: Tue, 11 Aug 2026 15:29:32 +0800 Subject: [PATCH] fix(chart): scope independent viewport resets --- README.md | 6 +- src/components/core/waveformChartTypes.ts | 3 +- src/components/data/types.ts | 1 + .../interaction/useWaveformViewport.ts | 16 ++- .../viewportConstraintsAndReset.test.ts | 65 ---------- .../waveformChartCases/viewportReset.test.ts | 114 ++++++++++++++++++ src/index.ts | 1 + src/types/chart.ts | 6 + src/types/index.ts | 1 + 9 files changed, 144 insertions(+), 69 deletions(-) create mode 100644 src/components/waveformChartCases/viewportReset.test.ts diff --git a/README.md b/README.md index 639cbee..5a83bd7 100644 --- a/README.md +++ b/README.md @@ -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` | 标注新增、更新或删除 | diff --git a/src/components/core/waveformChartTypes.ts b/src/components/core/waveformChartTypes.ts index b434cfa..62fb436 100644 --- a/src/components/core/waveformChartTypes.ts +++ b/src/components/core/waveformChartTypes.ts @@ -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 ( diff --git a/src/components/data/types.ts b/src/components/data/types.ts index ea360c4..d0b7d5b 100644 --- a/src/components/data/types.ts +++ b/src/components/data/types.ts @@ -10,6 +10,7 @@ export type { WaveformOverlayMode, WaveformInteractionMode, WaveformZoomEndPayload, + WaveformZoomResetPayload, WaveformAnnotationStyle, WaveformAnnotation, WaveformRenderingOptions, diff --git a/src/components/interaction/useWaveformViewport.ts b/src/components/interaction/useWaveformViewport.ts index 1aa0054..5abbe22 100644 --- a/src/components/interaction/useWaveformViewport.ts +++ b/src/components/interaction/useWaveformViewport.ts @@ -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, diff --git a/src/components/waveformChartCases/viewportConstraintsAndReset.test.ts b/src/components/waveformChartCases/viewportConstraintsAndReset.test.ts index def3de2..17950ac 100644 --- a/src/components/waveformChartCases/viewportConstraintsAndReset.test.ts +++ b/src/components/waveformChartCases/viewportConstraintsAndReset.test.ts @@ -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') - }) }) diff --git a/src/components/waveformChartCases/viewportReset.test.ts b/src/components/waveformChartCases/viewportReset.test.ts new file mode 100644 index 0000000..08039c4 --- /dev/null +++ b/src/components/waveformChartCases/viewportReset.test.ts @@ -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') + }) +}) diff --git a/src/index.ts b/src/index.ts index d5812f2..bd9fff6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ export type { WaveformOverlayMode, WaveformInteractionMode, WaveformZoomEndPayload, + WaveformZoomResetPayload, WaveformAnnotationStyle, WaveformAnnotation, WaveformRenderingOptions, diff --git a/src/types/chart.ts b/src/types/chart.ts index 610c087..c660a99 100644 --- a/src/types/chart.ts +++ b/src/types/chart.ts @@ -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 diff --git a/src/types/index.ts b/src/types/index.ts index 7dd1fa5..e376a8c 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -9,6 +9,7 @@ export type { WaveformOverlayMode, WaveformInteractionMode, WaveformZoomEndPayload, + WaveformZoomResetPayload, WaveformAnnotationStyle, WaveformAnnotation, WaveformRenderingOptions,