fix(chart): support wheel zoom out
All checks were successful
Package component / package (push) Successful in 3m50s

This commit is contained in:
李启源
2026-07-31 12:07:08 +08:00
parent 979b698156
commit f98c264562
9 changed files with 200 additions and 16 deletions

View File

@@ -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`

View File

@@ -1,6 +1,6 @@
{ {
"name": "waveform-analysis", "name": "waveform-analysis",
"version": "0.1.26", "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

@@ -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

@@ -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()
}
})
})