feat(chart): emit zoom completion payload
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -9,6 +9,7 @@ export type {
|
||||
WaveformDisplayMode,
|
||||
WaveformOverlayMode,
|
||||
WaveformInteractionMode,
|
||||
WaveformZoomEndPayload,
|
||||
WaveformAnnotationStyle,
|
||||
WaveformAnnotation,
|
||||
WaveformRenderingOptions,
|
||||
|
||||
@@ -7,6 +7,7 @@ export type {
|
||||
WaveformDisplayMode,
|
||||
WaveformOverlayMode,
|
||||
WaveformInteractionMode,
|
||||
WaveformZoomEndPayload,
|
||||
WaveformAnnotationStyle,
|
||||
WaveformAnnotation,
|
||||
WaveformRenderingOptions,
|
||||
|
||||
@@ -424,7 +424,6 @@ watch(
|
||||
>
|
||||
暂无可见曲线
|
||||
</text>
|
||||
|
||||
</g>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user