fix(chart): support wheel zoom out
This commit is contained in:
@@ -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`。
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
114
src/components/waveformChartCases/wheelZoomOut.test.ts
Normal file
114
src/components/waveformChartCases/wheelZoomOut.test.ts
Normal 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()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user