feat(chart): emit zoom completion payload

This commit is contained in:
李启源
2026-07-21 12:09:00 +08:00
parent e5ce7375cb
commit 32e8cf5856
14 changed files with 461 additions and 21 deletions

View File

@@ -1,5 +1,5 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { flushAnimationFrames, pendingAnimationFrameCount, resizeObservers } from '../test/setup'
import WaveformChart from './WaveformChart.vue'
@@ -2085,6 +2085,82 @@ describe('WaveformChart', () => {
expect(endpoints()[1]).toBe(initialEndpoints[1])
})
it('emits one zoom-end payload after a shared zoom gesture completes', 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 }),
})
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -4000,
clientX: overlayWidth / 2,
clientY: 145,
bubbles: true,
cancelable: true,
}),
)
expect(wrapper.emitted('zoom-end')).toBeUndefined()
flushAnimationFrames()
// Wait for zoom-end debounce (internal throttle + flush)
await vi.advanceTimersByTimeAsync(200)
await flushPromises()
const endEvents = wrapper.emitted('zoom-end') ?? []
expect(endEvents).toHaveLength(1)
const payload = endEvents[0]?.[0] as { start: number; end: number }
expect(payload.start).toBeGreaterThanOrEqual(0)
expect(payload.end).toBeLessThanOrEqual(2)
expect(payload.start).toBeLessThan(payload.end)
} finally {
vi.useRealTimers()
}
})
it('includes track and series IDs in independent zoom-end payloads', async () => {
vi.useFakeTimers()
try {
const wrapper = await mountSizedChart(gridSeries(2), {
displayMode: 'independent',
grid: { rowCount: 1, columnCount: 2 },
})
const overlay = wrapper.findAll('.waveform-chart__overlay--independent')[0]
const overlayWidth = Number(overlay.attributes('width'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width: overlayWidth, height: 260 }),
})
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -4000,
clientX: overlayWidth / 2,
clientY: 130,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await vi.advanceTimersByTimeAsync(200)
await flushPromises()
const payload = wrapper.emitted('zoom-end')?.at(-1)?.[0] as
{ trackIndex: number; seriesIds: string[] } | undefined
expect(payload).toMatchObject({ trackIndex: 0, seriesIds: ['channel-0'] })
} finally {
vi.useRealTimers()
}
})
it('rebuilds cached domains only when the data reference changes', async () => {
const firstData: WaveformData = {
kind: 'points',
@@ -2105,6 +2181,51 @@ describe('WaveformChart', () => {
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
})
it('keeps controlled annotations when replacing the loaded data window', async () => {
const annotations = [
{ id: 'window-note', seriesId: 'series-0', x: 0.5, y: 0.5, text: '窗口标注' },
]
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
],
},
{ annotations },
)
expect(wrapper.find('[data-annotation-id="window-note"]').exists()).toBe(true)
await wrapper.setProps({
data: {
kind: 'points',
points: [
{ x: 2, y: 0 },
{ x: 3, y: 1 },
],
},
})
await flushPromises()
expect(wrapper.find('[data-annotation-id="window-note"]').exists()).toBe(false)
expect(annotations).toEqual([
{ id: 'window-note', seriesId: 'series-0', x: 0.5, y: 0.5, text: '窗口标注' },
])
await wrapper.setProps({
data: {
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
],
},
})
await flushPromises()
expect(wrapper.find('[data-annotation-id="window-note"]').exists()).toBe(true)
})
it('renders named multi-channel paths as independent tracks by default', async () => {
const wrapper = await mountSizedChart({
kind: 'series',

View File

@@ -39,6 +39,7 @@ import {
type WaveformPoint,
type WaveformRenderingOptions,
type WaveformTitleOptions,
type WaveformZoomEndPayload,
} from './data/types'
import {
ANNOTATION_AMBIGUITY_DISTANCE,
@@ -128,6 +129,7 @@ const props = withDefaults(
const emit = defineEmits<{
'point-hover': [point: WaveformPoint | null]
'zoom-change': [domain: [number, number]]
'zoom-end': [payload: WaveformZoomEndPayload]
'update:annotations': [annotations: WaveformAnnotation[]]
'update:annotations-visible': [visible: boolean]
'update:interaction-mode': [mode: WaveformInteractionMode]
@@ -173,6 +175,7 @@ let generatedAnnotationId = 0
let synchronizingZoomTransform = false
let pendingSharedZoomTransform: ZoomTransform | null = null
const pendingIndependentZoomTransforms = new Map<number, ZoomTransform>()
const lastZoomedTrackIndexes = new Set<number>()
const zoomThrottle = useAnimationFrameThrottle()
const hoverThrottle = useAnimationFrameThrottle()
const preparedSeries = usePreparedWaveformSeries(() => props.data, handleDataReferenceChange)
@@ -586,20 +589,24 @@ function commitPendingZoom() {
emit('zoom-change', [domain[0], domain[1]])
}
if (!pendingIndependentZoomTransforms.size) return
const nextTransforms = [...independentTransforms.value]
const changedTrackIndexes = Array.from(pendingIndependentZoomTransforms.keys())
pendingIndependentZoomTransforms.forEach((transform, trackIndex) => {
nextTransforms[trackIndex] = transform
})
pendingIndependentZoomTransforms.clear()
independentTransforms.value = nextTransforms
changedTrackIndexes.forEach((trackIndex) => {
const track = trackLayouts.value.find((item) => item.index === trackIndex)
if (!track) return
const domain = track.xScale.domain()
emit('zoom-change', [domain[0], domain[1]])
})
if (pendingIndependentZoomTransforms.size) {
const nextTransforms = [...independentTransforms.value]
const changedTrackIndexes = Array.from(pendingIndependentZoomTransforms.keys())
// Clear stale track indexes before recording the new batch
lastZoomedTrackIndexes.clear()
changedTrackIndexes.forEach((trackIndex) => lastZoomedTrackIndexes.add(trackIndex))
pendingIndependentZoomTransforms.forEach((transform, trackIndex) => {
nextTransforms[trackIndex] = transform
})
pendingIndependentZoomTransforms.clear()
independentTransforms.value = nextTransforms
changedTrackIndexes.forEach((trackIndex) => {
const track = trackLayouts.value.find((item) => item.index === trackIndex)
if (!track) return
const domain = track.xScale.domain()
emit('zoom-change', [domain[0], domain[1]])
})
}
}
function scheduleZoomCommit() {
@@ -609,11 +616,35 @@ function scheduleZoomCommit() {
function flushPendingZoom() {
zoomThrottle.flush()
commitPendingZoom()
emitZoomEnd()
}
function emitZoomEnd() {
if (props.displayMode === 'independent') {
lastZoomedTrackIndexes.forEach((trackIndex) => {
const track = trackLayouts.value.find((item) => item.index === trackIndex)
if (!track) return
const domain = track.xScale.domain() as [number, number]
emit('zoom-end', {
start: domain[0],
end: domain[1],
trackIndex,
seriesIds: track.seriesList.map((series) => series.id),
})
})
lastZoomedTrackIndexes.clear()
return
}
const domain = sharedZoomDomain.value
emit('zoom-end', { start: domain[0], end: domain[1] })
}
function cancelPendingZoom() {
// Clear all pending zoom state to prevent stale emissions
pendingSharedZoomTransform = null
pendingIndependentZoomTransforms.clear()
lastZoomedTrackIndexes.clear()
zoomThrottle.cancel()
}

View File

@@ -269,7 +269,9 @@ function chooseBestPlacement(
// Try to find a placement that fits completely within bounds
for (const placement of placements) {
if (isPlacementWithinBounds(anchorX, anchorY, width, height, placement, plotWidth, plotHeight)) {
if (
isPlacementWithinBounds(anchorX, anchorY, width, height, placement, plotWidth, plotHeight)
) {
return placement
}
}
@@ -423,8 +425,8 @@ export function layoutAnnotations(
// Choose best placement only if no manual offset exists
const hasManualOffset =
Number.isFinite(annotation.labelOffsetX) && annotation.labelOffsetX !== 0 ||
Number.isFinite(annotation.labelOffsetY) && annotation.labelOffsetY !== 0
(Number.isFinite(annotation.labelOffsetX) && annotation.labelOffsetX !== 0) ||
(Number.isFinite(annotation.labelOffsetY) && annotation.labelOffsetY !== 0)
const { width, height } = annotationBoxSize(lines, trackWidth, localHeight)
const placement: AnnotationPlacement = hasManualOffset

View File

@@ -9,6 +9,7 @@ export type {
WaveformDisplayMode,
WaveformOverlayMode,
WaveformInteractionMode,
WaveformZoomEndPayload,
WaveformAnnotationStyle,
WaveformAnnotation,
WaveformRenderingOptions,

View File

@@ -7,6 +7,7 @@ export type {
WaveformDisplayMode,
WaveformOverlayMode,
WaveformInteractionMode,
WaveformZoomEndPayload,
WaveformAnnotationStyle,
WaveformAnnotation,
WaveformRenderingOptions,

View File

@@ -424,7 +424,6 @@ watch(
>
暂无可见曲线
</text>
</g>
</template>