From c63ea2855e61c119a880b5da4256d0617fb9d9d8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=90=AF=E6=BA=90?= Date: Tue, 21 Jul 2026 09:18:46 +0800 Subject: [PATCH] fix(chart): guard deferred pointer updates --- src/components/WaveformChart.vue | 47 ++++++---------- .../utils/useAnimationFrameThrottle.ts | 56 +++++++++++++++++++ 2 files changed, 73 insertions(+), 30 deletions(-) create mode 100644 src/components/utils/useAnimationFrameThrottle.ts diff --git a/src/components/WaveformChart.vue b/src/components/WaveformChart.vue index 6dcf2d6..37f5260 100644 --- a/src/components/WaveformChart.vue +++ b/src/components/WaveformChart.vue @@ -13,6 +13,7 @@ import { } from 'd3' import { resolveWaveformRenderingOptions } from '../core' import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../utils' +import { useAnimationFrameThrottle } from './utils/useAnimationFrameThrottle' import { computed, nextTick, @@ -173,11 +174,10 @@ const annotationInteraction = useWaveformAnnotationInteraction() const editorSeriesOptions = ref([]) let generatedAnnotationId = 0 let synchronizingZoomTransform = false -let zoomAnimationFrame: number | null = null let pendingSharedZoomTransform: ZoomTransform | null = null const pendingIndependentZoomTransforms = new Map() -let hoverAnimationFrame: number | null = null -let pendingHoverUpdate: (() => void) | null = null +const zoomThrottle = useAnimationFrameThrottle() +const hoverThrottle = useAnimationFrameThrottle() const preparedSeries = usePreparedWaveformSeries(() => props.data, handleDataReferenceChange) // 用于传递给 WaveformTooltip 的接口 @@ -608,27 +608,18 @@ function commitPendingZoom() { } function scheduleZoomCommit() { - if (zoomAnimationFrame !== null) return - zoomAnimationFrame = requestAnimationFrame(() => { - zoomAnimationFrame = null - commitPendingZoom() - }) + zoomThrottle.schedule(() => commitPendingZoom()) } function flushPendingZoom() { - if (zoomAnimationFrame !== null) { - cancelAnimationFrame(zoomAnimationFrame) - zoomAnimationFrame = null - } + zoomThrottle.flush() commitPendingZoom() } function cancelPendingZoom() { pendingSharedZoomTransform = null pendingIndependentZoomTransforms.clear() - if (zoomAnimationFrame === null) return - cancelAnimationFrame(zoomAnimationFrame) - zoomAnimationFrame = null + zoomThrottle.cancel() } function clearZoomBindings() { @@ -705,21 +696,11 @@ function configureZoom() { } function cancelPendingHover() { - pendingHoverUpdate = null - if (hoverAnimationFrame === null) return - cancelAnimationFrame(hoverAnimationFrame) - hoverAnimationFrame = null + hoverThrottle.cancel() } function scheduleHover(update: () => void) { - pendingHoverUpdate = update - if (hoverAnimationFrame !== null) return - hoverAnimationFrame = requestAnimationFrame(() => { - hoverAnimationFrame = null - const nextUpdate = pendingHoverUpdate - pendingHoverUpdate = null - nextUpdate?.() - }) + hoverThrottle.schedule(update) } function hoveredPointsMatch(nextPoints: HoveredSeriesPoint[]): boolean { @@ -1051,9 +1032,15 @@ function handleIndependentPointerMove(event: PointerEvent, trackIndex: number) { const overlay = event.currentTarget as SVGRectElement | null if (!overlay) return const [pointerX, pointerY] = pointer(event, overlay) + // 捕获轨道对象以避免竞态条件 + const track = trackLayouts.value[trackIndex] + if (!track || !track.hasVisibleSeries) return + scheduleHover(() => { - const track = trackLayouts.value[trackIndex] - if (!track) return + // 重新验证轨道仍然有效且有可见系列 + const currentTrack = trackLayouts.value[trackIndex] + if (!currentTrack || !currentTrack.hasVisibleSeries || currentTrack !== track) return + const xValue = track.xScale.invert(Math.max(0, Math.min(innerWidth.value, pointerX))) const nextPoints = track.seriesList.flatMap((series) => { const point = nearestPoint(series, xValue) @@ -1177,7 +1164,7 @@ watch( ) if ( retainedIds.size !== internalHiddenSeriesIds.value.size || - Array.from(retainedIds).some((seriesId) => !internalHiddenSeriesIds.value.has(seriesId)) + Array.from(internalHiddenSeriesIds.value).some((seriesId) => !retainedIds.has(seriesId)) ) { internalHiddenSeriesIds.value = retainedIds } diff --git a/src/components/utils/useAnimationFrameThrottle.ts b/src/components/utils/useAnimationFrameThrottle.ts new file mode 100644 index 0000000..2b28f73 --- /dev/null +++ b/src/components/utils/useAnimationFrameThrottle.ts @@ -0,0 +1,56 @@ +/** + * 创建一个基于 requestAnimationFrame 的节流工具 + * 用于合并多个快速连续的调用到单个动画帧中 + */ +export function useAnimationFrameThrottle() { + let frameHandle: number | null = null + let pendingCallback: (() => T) | null = null + + /** + * 调度一个回调在下一个动画帧中执行 + * 如果已经有待处理的帧,则替换待处理的回调 + */ + function schedule(callback: () => T): void { + pendingCallback = callback + if (frameHandle !== null) return + + frameHandle = requestAnimationFrame(() => { + frameHandle = null + const cb = pendingCallback + pendingCallback = null + cb?.() + }) + } + + /** + * 取消待处理的动画帧调度 + */ + function cancel(): void { + pendingCallback = null + if (frameHandle === null) return + cancelAnimationFrame(frameHandle) + frameHandle = null + } + + /** + * 立即执行待处理的回调(如果有) + */ + function flush(): void { + if (frameHandle !== null) { + cancelAnimationFrame(frameHandle) + frameHandle = null + } + const cb = pendingCallback + pendingCallback = null + cb?.() + } + + /** + * 检查是否有待处理的调度 + */ + function isPending(): boolean { + return frameHandle !== null + } + + return { schedule, cancel, flush, isPending } +}