2 Commits

Author SHA1 Message Date
李启源
f98c264562 fix(chart): support wheel zoom out
All checks were successful
Package component / package (push) Successful in 3m50s
2026-07-31 12:07:08 +08:00
李启源
979b698156 fix(chart): retain axes in clean view
All checks were successful
Package component / package (push) Successful in 3m58s
2026-07-31 10:27:27 +08:00
12 changed files with 237 additions and 34 deletions

View File

@@ -119,7 +119,7 @@ const data = ref<WaveformData>({
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 | | `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
| `frameNumber` | `string \| number` | 未设置 | 图框水印内容 | | `frameNumber` | `string \| number` | 未设置 | 图框水印内容 |
| `zeroLine` | `WaveformZeroLineOptions` | `{ visible: false }` | 零值参考线显隐与样式 | | `zeroLine` | `WaveformZeroLineOptions` | `{ visible: false }` | 零值参考线显隐与样式 |
| `cleanView` | `boolean` | `false` | 保留波形的净图模式 | | `cleanView` | `boolean` | `false` | 保留波形、图框和刻度的净图模式 |
| `presentationMode` | `boolean` | `false` | 禁用绘图区交互的展示模式 | | `presentationMode` | `boolean` | `false` | 禁用绘图区交互的展示模式 |
| `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 | | `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 |
| `annotationsVisible` | `boolean` | `true` | 标注图层显隐 | | `annotationsVisible` | `boolean` | `true` | 标注图层显隐 |
@@ -219,7 +219,7 @@ import 'waveform-analysis/style.css'
组件支持 Plotly 风格的矩形框选缩放:在 zoom 模式下按住鼠标左键拖拽,松开后同时缩放 组件支持 Plotly 风格的矩形框选缩放:在 zoom 模式下按住鼠标左键拖拽,松开后同时缩放
X/Y 轴;设置 `pannable` 后,指针位于图表内时按住空格键拖拽可平移当前视口。 X/Y 轴;设置 `pannable` 后,指针位于图表内时按住空格键拖拽可平移当前视口。
鼠标滚轮可放大,双击恢复完整视口。 鼠标滚轮可放大和缩小,双击恢复完整视口。
组件会在滚轮或框选缩放结束后触发 `zoom-end`,调用方可以使用端点请求后端,再通过 组件会在滚轮或框选缩放结束后触发 `zoom-end`,调用方可以使用端点请求后端,再通过
`data` 传回新数据。独立分图模式还会包含 `trackIndex` 和稳定的 `seriesIds` `data` 传回新数据。独立分图模式还会包含 `trackIndex` 和稳定的 `seriesIds`
@@ -449,9 +449,10 @@ scale 定位零线:
`dash` 直接对应 SVG 的 `stroke-dasharray`;传入空字符串可显示实线。无效或非正数的 `dash` 直接对应 SVG 的 `stroke-dasharray`;传入空字符串可显示实线。无效或非正数的
`width` 会回退到 `1` `width` 会回退到 `1`
设置 `cleanView` 后,组件隐藏标题内容、图例、网格、坐标轴、轴标签、图框背景与边框、帧水印 设置 `cleanView` 后,组件保留波形、图框边框、X/Y 轴刻度及刻度值,并隐藏标题内容、图例、
零值参考线、标注和分页器,同时保留原图的标题区域、边距和波形尺寸。缩放、悬浮、十字线和 网格、轴标签、图框背景、帧水印、零值参考线、标注和分页器原图的标题区域、边距和波形
tooltip 仍然可用,切换回普通模式后原有配置和标注不会丢失: 尺寸保持不变;缩放、悬浮、十字线和 tooltip 仍然可用,切换回普通模式后原有配置和标注
不会丢失:
```vue ```vue
<WaveformChart :data="chartData" :clean-view="cleanViewEnabled" /> <WaveformChart :data="chartData" :clean-view="cleanViewEnabled" />

View File

@@ -1,6 +1,6 @@
{ {
"name": "waveform-analysis", "name": "waveform-analysis",
"version": "0.1.25", "version": "0.1.27",
"main": "./dist/index.cjs", "main": "./dist/index.cjs",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/types/index.d.ts", "types": "./dist/types/index.d.ts",

View File

@@ -86,6 +86,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
? scaleLinear( ? scaleLinear(
options.initialXDomains?.[displayTrack.id] ?? options.initialXDomains?.[displayTrack.id] ??
options.initialXDomains?.[series.id] ?? options.initialXDomains?.[series.id] ??
options.initialXDomain ??
displayTrack.xDomain, displayTrack.xDomain,
[0, cell.width], [0, cell.width],
) )

View File

@@ -3,6 +3,7 @@ import {
select, select,
zoom, zoom,
zoomIdentity, zoomIdentity,
zoomTransform,
type D3ZoomEvent, type D3ZoomEvent,
type ZoomBehavior, type ZoomBehavior,
type ZoomTransform, type ZoomTransform,
@@ -237,6 +238,15 @@ export function useWaveformZoom(context: ZoomContext) {
const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries) const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
return tracks.length > 0 && tracks.every(canZoomTrack) return tracks.length > 0 && tracks.every(canZoomTrack)
} }
const canHandleWheelZoom = (event: Event, canZoomIn: boolean): boolean => {
if (event.type !== 'wheel') return false
const deltaY = (event as WheelEvent).deltaY
if (deltaY < 0) return canZoomIn
if (deltaY > 0) {
return zoomTransform(event.currentTarget as Element).k > ZOOM_CONSTRAINTS.MIN_SCALE
}
return false
}
const configureZoom = () => { const configureZoom = () => {
clearZoomBindings() clearZoomBindings()
if ( if (
@@ -256,10 +266,7 @@ export function useWaveformZoom(context: ZoomContext) {
if (!overlay) return if (!overlay) return
const dataDomain = resolveInitialTrackDomain(track) const dataDomain = resolveInitialTrackDomain(track)
const behavior = zoom<SVGRectElement, unknown>() const behavior = zoom<SVGRectElement, unknown>()
.filter( .filter((event) => canHandleWheelZoom(event, canZoomTrack(track)))
(event) =>
event.type === 'wheel' && (event as WheelEvent).deltaY < 0 && canZoomTrack(track),
)
.scaleExtent([1, resolveMaximumZoomScale(dataDomain)]) .scaleExtent([1, resolveMaximumZoomScale(dataDomain)])
.extent([ .extent([
[0, 0], [0, 0],
@@ -287,10 +294,7 @@ export function useWaveformZoom(context: ZoomContext) {
const overlay = sharedOverlayElement.value const overlay = sharedOverlayElement.value
if (!overlay) return if (!overlay) return
const behavior = zoom<SVGRectElement, unknown>() const behavior = zoom<SVGRectElement, unknown>()
.filter( .filter((event) => canHandleWheelZoom(event, canZoomSharedTracks()))
(event) =>
event.type === 'wheel' && (event as WheelEvent).deltaY < 0 && canZoomSharedTracks(),
)
.scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value)]) .scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value)])
.extent([ .extent([
[0, 0], [0, 0],

View File

@@ -94,7 +94,7 @@ const resolvedFrameStyle = computed(() => {
/> />
<rect <rect
v-if="!track.isEmpty && !cleanView" v-if="!track.isEmpty"
class="waveform-track__plot-frame waveform-chart__plot-frame" class="waveform-track__plot-frame waveform-chart__plot-frame"
:width="track.width ?? innerWidth" :width="track.width ?? innerWidth"
:height="track.height" :height="track.height"

View File

@@ -103,14 +103,14 @@ watch(
<template> <template>
<g <g
v-if="track.showXAxis && !cleanView" v-if="track.showXAxis"
ref="xAxisElement" ref="xAxisElement"
class="waveform-track__axis waveform-track__axis--x waveform-chart__axis waveform-chart__axis--x" class="waveform-track__axis waveform-track__axis--x waveform-chart__axis waveform-chart__axis--x"
:class="{ 'waveform-track__axis--line-hidden': axes?.x?.lineVisible === false }" :class="{ 'waveform-track__axis--line-hidden': axes?.x?.lineVisible === false }"
:transform="`translate(0, ${track.height})`" :transform="`translate(0, ${track.height})`"
/> />
<g <g
v-if="track.showXAxis && !cleanView" v-if="track.showXAxis"
class="waveform-track__axis-endpoints waveform-chart__axis-endpoints" class="waveform-track__axis-endpoints waveform-chart__axis-endpoints"
:transform="`translate(0, ${track.height})`" :transform="`translate(0, ${track.height})`"
font-family="sans-serif" font-family="sans-serif"
@@ -137,7 +137,7 @@ watch(
</text> </text>
</g> </g>
<text <text
v-if="track.showXAxis && track.xAxisExponent && !cleanView" v-if="track.showXAxis && track.xAxisExponent"
class="waveform-track__axis-exponent waveform-track__axis-exponent--x waveform-chart__axis-exponent waveform-chart__axis-exponent--x" class="waveform-track__axis-exponent waveform-track__axis-exponent--x waveform-chart__axis-exponent waveform-chart__axis-exponent--x"
:x="track.width ?? innerWidth" :x="track.width ?? innerWidth"
:y="track.height + 27" :y="track.height + 27"
@@ -148,7 +148,7 @@ watch(
</text> </text>
<g <g
v-for="axis in track.isEmpty || cleanView ? [] : track.yAxes" v-for="axis in track.isEmpty ? [] : track.yAxes"
:key="`y-axis-${track.index}-${axis.index}`" :key="`y-axis-${track.index}-${axis.index}`"
:ref="(element) => setYAxisElement(element, axis.index)" :ref="(element) => setYAxisElement(element, axis.index)"
class="waveform-track__axis waveform-track__axis--y waveform-chart__axis waveform-chart__axis--y" class="waveform-track__axis waveform-track__axis--y waveform-chart__axis waveform-chart__axis--y"
@@ -161,9 +161,7 @@ watch(
:transform="`translate(${axis.x}, 0)`" :transform="`translate(${axis.x}, 0)`"
/> />
<text <text
v-for="axis in track.isEmpty || cleanView v-for="axis in track.isEmpty ? [] : track.yAxes.filter((item) => item.exponentLabel)"
? []
: track.yAxes.filter((item) => item.exponentLabel)"
:key="`y-axis-exponent-${track.index}-${axis.index}`" :key="`y-axis-exponent-${track.index}-${axis.index}`"
class="waveform-track__axis-exponent waveform-track__axis-exponent--y waveform-chart__axis-exponent waveform-chart__axis-exponent--y" class="waveform-track__axis-exponent waveform-track__axis-exponent--y waveform-chart__axis-exponent waveform-chart__axis-exponent--y"
:data-y-axis-index="axis.index" :data-y-axis-index="axis.index"

View File

@@ -225,7 +225,7 @@ describe('WaveformChart', () => {
} }
}) })
it('uses each track x domain in independent mode instead of the shared initial domain', async () => { it('uses the shared initial x domain for every independent track by default', async () => {
const wrapper = await mountSizedChart( const wrapper = await mountSizedChart(
{ {
kind: 'series', kind: 'series',
@@ -262,7 +262,9 @@ describe('WaveformChart', () => {
) )
const tracks = wrapper.findAll('.waveform-chart__track') const tracks = wrapper.findAll('.waveform-chart__track')
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('6.00') expect(tracks[0]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8.00')
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5.00')
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8.00')
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5.00') expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5.00')
}) })

View File

@@ -146,7 +146,7 @@ describe('WaveformChart', () => {
kind: 'points', kind: 'points',
points: [ points: [
{ x: 0, y: -1 }, { x: 0, y: -1 },
{ x: 1, y: 1 }, { x: 10_000, y: 1 },
], ],
}, },
}, },
@@ -227,8 +227,8 @@ describe('WaveformChart', () => {
data: { data: {
kind: 'points', kind: 'points',
points: [ points: [
{ x: 0, y: 1 }, { x: 0, y: 10_000 },
{ x: 1, y: 2 }, { x: 10_000, y: 20_000 },
], ],
}, },
}, },
@@ -239,7 +239,7 @@ describe('WaveformChart', () => {
kind: 'points', kind: 'points',
points: [ points: [
{ x: 0, y: 2 }, { x: 0, y: 2 },
{ x: 1, y: 3 }, { x: 10_000, y: 3 },
], ],
}, },
}, },
@@ -252,6 +252,8 @@ describe('WaveformChart', () => {
annotations: [{ id: 'note', seriesId: 'first', x: 0.5, y: 0, text: 'hidden note' }], annotations: [{ id: 'note', seriesId: 'first', x: 0.5, y: 0, text: 'hidden note' }],
zeroLine: { visible: true }, zeroLine: { visible: true },
title: { text: 'hidden title' }, title: { text: 'hidden title' },
axes: { x: { lineVisible: false }, y: { lineVisible: false } },
frameStyle: { borderColor: '#dc2626', borderWidth: 2, borderStyle: 'dashed' as const },
} }
const regularWrapper = await mountSizedChart(data, sharedProps) const regularWrapper = await mountSizedChart(data, sharedProps)
const wrapper = await mountSizedChart(data, { const wrapper = await mountSizedChart(data, {
@@ -285,13 +287,31 @@ describe('WaveformChart', () => {
expect(wrapper.find('.waveform-chart__overlay--independent').exists()).toBe(true) expect(wrapper.find('.waveform-chart__overlay--independent').exists()).toBe(true)
expect(wrapper.get('.waveform-chart__title-area').attributes('aria-hidden')).toBe('true') expect(wrapper.get('.waveform-chart__title-area').attributes('aria-hidden')).toBe('true')
expect(wrapper.find('.waveform-chart__title-visual').exists()).toBe(false) expect(wrapper.find('.waveform-chart__title-visual').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__axis').exists()).toBe(false) const xAxis = wrapper.get('.waveform-chart__axis--x')
const yAxes = wrapper.findAll('.waveform-chart__axis--y')
expect(yAxes).toHaveLength(2)
expect(xAxis.findAll('.tick line').length).toBeGreaterThan(0)
expect(xAxis.findAll('.tick text').length).toBeGreaterThan(0)
yAxes.forEach((axis) => {
expect(axis.findAll('.tick line').length).toBeGreaterThan(0)
expect(axis.findAll('.tick text').length).toBeGreaterThan(0)
expect(axis.get('path.domain').attributes('display')).toBe('none')
})
expect(xAxis.get('path.domain').attributes('display')).toBe('none')
expect(wrapper.findAll('.waveform-chart__axis-endpoint')).toHaveLength(2)
expect(wrapper.get('.waveform-chart__axis-exponent--x').text()).not.toBe('')
expect(wrapper.get('.waveform-chart__axis-exponent--y').text()).not.toBe('')
expect(wrapper.find('.waveform-chart__grid').exists()).toBe(false) expect(wrapper.find('.waveform-chart__grid').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__plot-frame').exists()).toBe(false) expect(wrapper.get('.waveform-chart__plot-frame').attributes()).toMatchObject({
stroke: '#dc2626',
'stroke-width': '2',
'stroke-dasharray': '6 4',
})
expect(wrapper.find('.waveform-chart__plot-background').exists()).toBe(false) expect(wrapper.find('.waveform-chart__plot-background').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__watermark').exists()).toBe(false) expect(wrapper.find('.waveform-chart__watermark').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__legend-layer').exists()).toBe(false) expect(wrapper.find('.waveform-chart__legend-layer').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__label').exists()).toBe(false) expect(wrapper.find('.waveform-chart__label').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__y-axis-label').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__zero-line').exists()).toBe(false) expect(wrapper.find('.waveform-chart__zero-line').exists()).toBe(false)
expect(wrapper.find('.waveform-annotation-layer').exists()).toBe(false) expect(wrapper.find('.waveform-annotation-layer').exists()).toBe(false)
expect(wrapper.find('.ant-pagination').exists()).toBe(false) expect(wrapper.find('.ant-pagination').exists()).toBe(false)

View File

@@ -241,7 +241,7 @@ describe('WaveformChart presentation mode', () => {
await flushPromises() await flushPromises()
overlay.element.dispatchEvent( overlay.element.dispatchEvent(
new WheelEvent('wheel', { new WheelEvent('wheel', {
deltaY: -200, deltaY: 200,
clientX: width / 2, clientX: width / 2,
clientY: height / 2, clientY: height / 2,
bubbles: true, bubbles: true,

View File

@@ -38,6 +38,21 @@ describe('WaveformChart', () => {
expect(domain[1]).toBeLessThanOrEqual(1) expect(domain[1]).toBeLessThanOrEqual(1)
expect(endpoints()[0]).not.toBe(initialEndpoints[0]) expect(endpoints()[0]).not.toBe(initialEndpoints[0])
expect(endpoints()[1]).toBe(initialEndpoints[1]) expect(endpoints()[1]).toBe(initialEndpoints[1])
overlays[0].element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: 4_000,
clientX: firstWidth - 1,
clientY: 130,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
expect(endpoints()[0]).toBe(initialEndpoints[0])
expect(endpoints()[1]).toBe(initialEndpoints[1])
}) })
it('emits one zoom-end payload after a shared zoom gesture completes', async () => { it('emits one zoom-end payload after a shared zoom gesture completes', async () => {

View File

@@ -207,7 +207,7 @@ describe('WaveformChart', () => {
expect(secondDomain[1] - secondDomain[0]).toBeCloseTo(10) expect(secondDomain[1] - secondDomain[0]).toBeCloseTo(10)
}) })
it('keeps one global domain while loading narrower and wider windows', async () => { it('zooms a shared viewport back out after loading a narrower data window', async () => {
const wrapper = await mountSizedChart( const wrapper = await mountSizedChart(
{ {
kind: 'points', kind: 'points',
@@ -256,9 +256,57 @@ describe('WaveformChart', () => {
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0 const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
await dispatchWheel(4000) await dispatchWheel(4000)
const currentDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number] const zoomedOutDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount + 1)
expect(zoomedOutDomain[1] - zoomedOutDomain[0]).toBeCloseTo(100)
const fullDomainEventCount = wrapper.emitted('zoom-change')?.length ?? 0
await dispatchWheel(4000)
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(fullDomainEventCount)
})
it('allows zooming out when the minimum visible point count blocks zooming in', async () => {
const wrapper = await mountSizedChart({
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 10 },
{ x: 2, y: 20 },
],
})
const overlay = wrapper.get('.waveform-chart__overlay--independent')
const width = Number(overlay.attributes('width'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height: 290 }),
})
const dispatchWheel = async (deltaY: number) => {
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY,
clientX: width / 2,
clientY: 145,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
}
await dispatchWheel(-4000)
const zoomedDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
expect(zoomedDomain[1] - zoomedDomain[0]).toBeCloseTo(0.05)
await wrapper.setProps({ minVisiblePoints: 10 })
await flushPromises()
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
await dispatchWheel(-1000)
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount) expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount)
expect(currentDomain[1] - currentDomain[0]).toBeCloseTo(2.5)
await dispatchWheel(4000)
const zoomedOutDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
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 () => { it('resets a shared viewport on double-click and emits zoom-reset', async () => {

View File

@@ -0,0 +1,114 @@
import { flushPromises } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { flushAnimationFrames } from '../../test/setup'
import { mountSizedChart } from '../../test/waveformChart'
describe('WaveformChart wheel zoom out', () => {
it('restores an independent initial domain after replacing data with a narrow window', async () => {
const createData = (start: number, end: number) => ({
kind: 'series' as const,
series: [
{
id: 'channel-a',
name: '通道 A',
data: {
kind: 'points' as const,
points: [
{ x: start, y: 0 },
{ x: end, y: 1 },
],
},
},
],
})
const wrapper = await mountSizedChart(createData(-5, 5), {
displayMode: 'independent',
initialXDomain: [-5, 5],
})
const overlay = wrapper.get('.waveform-chart__overlay--independent')
const overlayWidth = Number(overlay.attributes('width'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width: overlayWidth, height: 290 }),
})
const dispatchWheel = async (deltaY: number) => {
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY,
clientX: overlayWidth / 2,
clientY: 145,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
}
const endpoints = () => ({
start: wrapper.get('.waveform-chart__axis-endpoint--start').text(),
end: wrapper.get('.waveform-chart__axis-endpoint--end').text(),
})
expect(endpoints()).toEqual({ start: '-5.00', end: '5.00' })
await dispatchWheel(-4000)
expect(endpoints()).not.toEqual({ start: '-5.00', end: '5.00' })
await wrapper.setProps({ data: createData(-0.125, 0.125) })
await flushPromises()
await dispatchWheel(4000)
expect(endpoints()).toEqual({ start: '-5.00', end: '5.00' })
})
it('emits one zoom-end payload after zooming a shared viewport out', async () => {
vi.useFakeTimers()
try {
const wrapper = await mountSizedChart({
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 2, y: 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 dispatchWheel = (deltaY: number) =>
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY,
clientX: overlayWidth / 2,
clientY: 145,
bubbles: true,
cancelable: true,
}),
)
dispatchWheel(-4000)
flushAnimationFrames()
await vi.advanceTimersByTimeAsync(200)
await flushPromises()
const zoomedDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
const zoomedSpan = zoomedDomain[1] - zoomedDomain[0]
dispatchWheel(1000)
flushAnimationFrames()
expect(wrapper.emitted('zoom-end')).toHaveLength(1)
await vi.advanceTimersByTimeAsync(200)
await flushPromises()
const zoomedOutPayload = wrapper.emitted('zoom-end')?.at(-1)?.[0] as {
start: number
end: number
gesture: string
}
expect(wrapper.emitted('zoom-end')).toHaveLength(2)
expect(zoomedOutPayload.end - zoomedOutPayload.start).toBeGreaterThan(zoomedSpan)
expect(zoomedOutPayload.gesture).toBe('wheel')
} finally {
vi.useRealTimers()
}
})
})