fix(chart): guard deferred pointer updates

This commit is contained in:
李启源
2026-07-21 09:18:46 +08:00
parent 671fde76f7
commit c63ea2855e
2 changed files with 73 additions and 30 deletions

View File

@@ -13,6 +13,7 @@ import {
} from 'd3' } from 'd3'
import { resolveWaveformRenderingOptions } from '../core' import { resolveWaveformRenderingOptions } from '../core'
import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../utils' import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../utils'
import { useAnimationFrameThrottle } from './utils/useAnimationFrameThrottle'
import { import {
computed, computed,
nextTick, nextTick,
@@ -173,11 +174,10 @@ const annotationInteraction = useWaveformAnnotationInteraction()
const editorSeriesOptions = ref<AnnotationSeriesCandidate[]>([]) const editorSeriesOptions = ref<AnnotationSeriesCandidate[]>([])
let generatedAnnotationId = 0 let generatedAnnotationId = 0
let synchronizingZoomTransform = false let synchronizingZoomTransform = false
let zoomAnimationFrame: number | null = null
let pendingSharedZoomTransform: ZoomTransform | null = null let pendingSharedZoomTransform: ZoomTransform | null = null
const pendingIndependentZoomTransforms = new Map<number, ZoomTransform>() const pendingIndependentZoomTransforms = new Map<number, ZoomTransform>()
let hoverAnimationFrame: number | null = null const zoomThrottle = useAnimationFrameThrottle()
let pendingHoverUpdate: (() => void) | null = null const hoverThrottle = useAnimationFrameThrottle()
const preparedSeries = usePreparedWaveformSeries(() => props.data, handleDataReferenceChange) const preparedSeries = usePreparedWaveformSeries(() => props.data, handleDataReferenceChange)
// 用于传递给 WaveformTooltip 的接口 // 用于传递给 WaveformTooltip 的接口
@@ -608,27 +608,18 @@ function commitPendingZoom() {
} }
function scheduleZoomCommit() { function scheduleZoomCommit() {
if (zoomAnimationFrame !== null) return zoomThrottle.schedule(() => commitPendingZoom())
zoomAnimationFrame = requestAnimationFrame(() => {
zoomAnimationFrame = null
commitPendingZoom()
})
} }
function flushPendingZoom() { function flushPendingZoom() {
if (zoomAnimationFrame !== null) { zoomThrottle.flush()
cancelAnimationFrame(zoomAnimationFrame)
zoomAnimationFrame = null
}
commitPendingZoom() commitPendingZoom()
} }
function cancelPendingZoom() { function cancelPendingZoom() {
pendingSharedZoomTransform = null pendingSharedZoomTransform = null
pendingIndependentZoomTransforms.clear() pendingIndependentZoomTransforms.clear()
if (zoomAnimationFrame === null) return zoomThrottle.cancel()
cancelAnimationFrame(zoomAnimationFrame)
zoomAnimationFrame = null
} }
function clearZoomBindings() { function clearZoomBindings() {
@@ -705,21 +696,11 @@ function configureZoom() {
} }
function cancelPendingHover() { function cancelPendingHover() {
pendingHoverUpdate = null hoverThrottle.cancel()
if (hoverAnimationFrame === null) return
cancelAnimationFrame(hoverAnimationFrame)
hoverAnimationFrame = null
} }
function scheduleHover(update: () => void) { function scheduleHover(update: () => void) {
pendingHoverUpdate = update hoverThrottle.schedule(update)
if (hoverAnimationFrame !== null) return
hoverAnimationFrame = requestAnimationFrame(() => {
hoverAnimationFrame = null
const nextUpdate = pendingHoverUpdate
pendingHoverUpdate = null
nextUpdate?.()
})
} }
function hoveredPointsMatch(nextPoints: HoveredSeriesPoint[]): boolean { function hoveredPointsMatch(nextPoints: HoveredSeriesPoint[]): boolean {
@@ -1051,9 +1032,15 @@ function handleIndependentPointerMove(event: PointerEvent, trackIndex: number) {
const overlay = event.currentTarget as SVGRectElement | null const overlay = event.currentTarget as SVGRectElement | null
if (!overlay) return if (!overlay) return
const [pointerX, pointerY] = pointer(event, overlay) const [pointerX, pointerY] = pointer(event, overlay)
// 捕获轨道对象以避免竞态条件
const track = trackLayouts.value[trackIndex]
if (!track || !track.hasVisibleSeries) return
scheduleHover(() => { 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 xValue = track.xScale.invert(Math.max(0, Math.min(innerWidth.value, pointerX)))
const nextPoints = track.seriesList.flatMap((series) => { const nextPoints = track.seriesList.flatMap((series) => {
const point = nearestPoint(series, xValue) const point = nearestPoint(series, xValue)
@@ -1177,7 +1164,7 @@ watch(
) )
if ( if (
retainedIds.size !== internalHiddenSeriesIds.value.size || 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 internalHiddenSeriesIds.value = retainedIds
} }

View File

@@ -0,0 +1,56 @@
/**
* 创建一个基于 requestAnimationFrame 的节流工具
* 用于合并多个快速连续的调用到单个动画帧中
*/
export function useAnimationFrameThrottle<T = void>() {
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 }
}