diff --git a/README.md b/README.md index abfa11f..9619ecb 100644 --- a/README.md +++ b/README.md @@ -219,7 +219,7 @@ import 'waveform-analysis/style.css' 组件支持 Plotly 风格的矩形框选缩放:在 zoom 模式下按住鼠标左键拖拽,松开后同时缩放 X/Y 轴;设置 `pannable` 后,指针位于图表内时按住空格键拖拽可平移当前视口。 -鼠标滚轮仍可放大,双击恢复完整视口。 +鼠标滚轮可放大和缩小,双击恢复完整视口。 组件会在滚轮或框选缩放结束后触发 `zoom-end`,调用方可以使用端点请求后端,再通过 `data` 传回新数据。独立分图模式还会包含 `trackIndex` 和稳定的 `seriesIds`。 diff --git a/package.json b/package.json index fb4c25c..1731e1f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "waveform-analysis", - "version": "0.1.26", + "version": "0.1.27", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/types/index.d.ts", diff --git a/src/components/core/trackLayoutBuilder.ts b/src/components/core/trackLayoutBuilder.ts index 66ba3be..6183576 100644 --- a/src/components/core/trackLayoutBuilder.ts +++ b/src/components/core/trackLayoutBuilder.ts @@ -86,6 +86,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou ? scaleLinear( options.initialXDomains?.[displayTrack.id] ?? options.initialXDomains?.[series.id] ?? + options.initialXDomain ?? displayTrack.xDomain, [0, cell.width], ) diff --git a/src/components/interaction/useWaveformZoom.ts b/src/components/interaction/useWaveformZoom.ts index a25a84c..e3bcc3a 100644 --- a/src/components/interaction/useWaveformZoom.ts +++ b/src/components/interaction/useWaveformZoom.ts @@ -3,6 +3,7 @@ import { select, zoom, zoomIdentity, + zoomTransform, type D3ZoomEvent, type ZoomBehavior, type ZoomTransform, @@ -237,6 +238,15 @@ export function useWaveformZoom(context: ZoomContext) { const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries) 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 = () => { clearZoomBindings() if ( @@ -256,10 +266,7 @@ export function useWaveformZoom(context: ZoomContext) { if (!overlay) return const dataDomain = resolveInitialTrackDomain(track) const behavior = zoom() - .filter( - (event) => - event.type === 'wheel' && (event as WheelEvent).deltaY < 0 && canZoomTrack(track), - ) + .filter((event) => canHandleWheelZoom(event, canZoomTrack(track))) .scaleExtent([1, resolveMaximumZoomScale(dataDomain)]) .extent([ [0, 0], @@ -287,10 +294,7 @@ export function useWaveformZoom(context: ZoomContext) { const overlay = sharedOverlayElement.value if (!overlay) return const behavior = zoom() - .filter( - (event) => - event.type === 'wheel' && (event as WheelEvent).deltaY < 0 && canZoomSharedTracks(), - ) + .filter((event) => canHandleWheelZoom(event, canZoomSharedTracks())) .scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value)]) .extent([ [0, 0], diff --git a/src/components/waveformChartCases/layoutAndDomains.test.ts b/src/components/waveformChartCases/layoutAndDomains.test.ts index cd4f7cc..9044c90 100644 --- a/src/components/waveformChartCases/layoutAndDomains.test.ts +++ b/src/components/waveformChartCases/layoutAndDomains.test.ts @@ -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( { kind: 'series', @@ -262,7 +262,9 @@ describe('WaveformChart', () => { ) 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') }) diff --git a/src/components/waveformChartCases/presentationMode.test.ts b/src/components/waveformChartCases/presentationMode.test.ts index 4b166a8..adc8f19 100644 --- a/src/components/waveformChartCases/presentationMode.test.ts +++ b/src/components/waveformChartCases/presentationMode.test.ts @@ -241,7 +241,7 @@ describe('WaveformChart presentation mode', () => { await flushPromises() overlay.element.dispatchEvent( new WheelEvent('wheel', { - deltaY: -200, + deltaY: 200, clientX: width / 2, clientY: height / 2, bubbles: true, diff --git a/src/components/waveformChartCases/viewportBoxAndPan.test.ts b/src/components/waveformChartCases/viewportBoxAndPan.test.ts index 33091d7..70b1851 100644 --- a/src/components/waveformChartCases/viewportBoxAndPan.test.ts +++ b/src/components/waveformChartCases/viewportBoxAndPan.test.ts @@ -38,6 +38,21 @@ describe('WaveformChart', () => { expect(domain[1]).toBeLessThanOrEqual(1) expect(endpoints()[0]).not.toBe(initialEndpoints[0]) 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 () => { diff --git a/src/components/waveformChartCases/viewportConstraintsAndReset.test.ts b/src/components/waveformChartCases/viewportConstraintsAndReset.test.ts index 61fc597..f9c777d 100644 --- a/src/components/waveformChartCases/viewportConstraintsAndReset.test.ts +++ b/src/components/waveformChartCases/viewportConstraintsAndReset.test.ts @@ -207,7 +207,7 @@ describe('WaveformChart', () => { 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( { kind: 'points', @@ -256,9 +256,57 @@ describe('WaveformChart', () => { const eventCount = wrapper.emitted('zoom-change')?.length ?? 0 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(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 () => { diff --git a/src/components/waveformChartCases/wheelZoomOut.test.ts b/src/components/waveformChartCases/wheelZoomOut.test.ts new file mode 100644 index 0000000..ff54759 --- /dev/null +++ b/src/components/waveformChartCases/wheelZoomOut.test.ts @@ -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() + } + }) +})