fix(chart): harden interaction and axis caching
This commit is contained in:
@@ -74,11 +74,7 @@ import {
|
||||
type WaveformGridOptions,
|
||||
} from './core/grid'
|
||||
import type { DisplaySeries, DisplayTrack, HoveredSeriesPoint, TrackLayout } from './core/types'
|
||||
import {
|
||||
buildTrackLayouts,
|
||||
measureTrackYAxisClearance,
|
||||
Y_AXIS_EXPONENT_GAP,
|
||||
} from './core/layout'
|
||||
import { buildTrackLayouts, measureTrackYAxisClearance, Y_AXIS_EXPONENT_GAP } from './core/layout'
|
||||
import { calculateRotatedTitleLayout, TITLE_AREA_HORIZONTAL_PADDING } from './core/title'
|
||||
import { usePreparedWaveformSeries } from './core/useWaveformData'
|
||||
import WaveformAnnotationEditor from './annotation/WaveformAnnotationEditor.vue'
|
||||
@@ -360,9 +356,7 @@ const yAxisMetrics = computed(() => {
|
||||
0,
|
||||
...axisText.map(({ exponentLabel }) => (exponentLabel?.length ?? 0) * yAxisCharacterWidth),
|
||||
)
|
||||
const exponentClearance = maximumExponentWidth
|
||||
? maximumExponentWidth + Y_AXIS_EXPONENT_GAP
|
||||
: 0
|
||||
const exponentClearance = maximumExponentWidth ? maximumExponentWidth + Y_AXIS_EXPONENT_GAP : 0
|
||||
const tickClearance = tickTextWidth + yAxisTickPadding + exponentClearance + yAxisOuterPadding
|
||||
const labelCenterX = -(
|
||||
yAxisTickPadding +
|
||||
@@ -879,9 +873,14 @@ function resolveTrackAtPointer(
|
||||
if (pointerY > track.top + track.height) return pointerY - (track.top + track.height)
|
||||
return xDistance
|
||||
}
|
||||
// 修复 O(n²) 问题:缓存距离计算结果
|
||||
const trackDistances = new Map<TrackLayout, number>()
|
||||
visibleTracks.forEach((track) => {
|
||||
trackDistances.set(track, distanceToTrack(track))
|
||||
})
|
||||
return visibleTracks.reduce((closest, candidate) => {
|
||||
const distance = distanceToTrack(candidate)
|
||||
const closestDistance = distanceToTrack(closest)
|
||||
const distance = trackDistances.get(candidate)!
|
||||
const closestDistance = trackDistances.get(closest)!
|
||||
if (distance !== closestDistance) return distance < closestDistance ? candidate : closest
|
||||
const centerDistance = Math.abs(pointerY - (candidate.top + candidate.height / 2))
|
||||
const closestCenterDistance = Math.abs(pointerY - (closest.top + closest.height / 2))
|
||||
@@ -1181,8 +1180,13 @@ watch(
|
||||
clearHover()
|
||||
editorSeriesOptions.value = []
|
||||
const draftSeriesId = annotationInteraction.editorDraft.value?.annotation.seriesId
|
||||
if (draftSeriesId && hiddenSeriesIdSet.value.has(draftSeriesId)) {
|
||||
annotationInteraction.closeEditor()
|
||||
// 修复:不仅检查系列是否被隐藏,还要检查系列是否从数据中完全移除
|
||||
if (draftSeriesId) {
|
||||
const seriesExists = chartSeries.value.some((series) => series.id === draftSeriesId)
|
||||
const seriesHidden = hiddenSeriesIdSet.value.has(draftSeriesId)
|
||||
if (!seriesExists || seriesHidden) {
|
||||
annotationInteraction.closeEditor()
|
||||
}
|
||||
}
|
||||
const contextAnnotationId = annotationInteraction.contextMenu.value?.annotationId
|
||||
const contextAnnotation = props.annotations.find((item) => item.id === contextAnnotationId)
|
||||
|
||||
@@ -52,18 +52,39 @@ function resolveAxisSides(axisCount: number): Array<'left' | 'right'> {
|
||||
return ['left']
|
||||
}
|
||||
|
||||
// 缓存 axis groups 计算结果,避免重复计算
|
||||
const yAxisGroupsCache = new WeakMap<DisplayTrack, Map<WaveformOverlayMode, YAxisSeriesGroup[]>>()
|
||||
// Cache across recreated track objects without reusing groups whose axis-relevant data changed.
|
||||
const yAxisGroupsCache = new Map<string, Map<WaveformOverlayMode, YAxisSeriesGroup[]>>()
|
||||
const MAX_CACHE_SIZE = 100
|
||||
|
||||
function getCacheKey(track: DisplayTrack): string {
|
||||
return JSON.stringify([
|
||||
track.id,
|
||||
track.yDomain,
|
||||
track.visibleSeries.map((series) => [
|
||||
series.id,
|
||||
series.name,
|
||||
series.unit,
|
||||
series.color,
|
||||
series.yDomain,
|
||||
]),
|
||||
])
|
||||
}
|
||||
|
||||
export function buildYAxisSeriesGroups(
|
||||
track: DisplayTrack,
|
||||
overlayMode: WaveformOverlayMode,
|
||||
): YAxisSeriesGroup[] {
|
||||
// 检查缓存
|
||||
let trackCache = yAxisGroupsCache.get(track)
|
||||
const cacheKey = getCacheKey(track)
|
||||
let trackCache = yAxisGroupsCache.get(cacheKey)
|
||||
if (!trackCache) {
|
||||
trackCache = new Map()
|
||||
yAxisGroupsCache.set(track, trackCache)
|
||||
yAxisGroupsCache.set(cacheKey, trackCache)
|
||||
if (yAxisGroupsCache.size > MAX_CACHE_SIZE) {
|
||||
const firstKey = yAxisGroupsCache.keys().next().value
|
||||
if (firstKey !== undefined) {
|
||||
yAxisGroupsCache.delete(firstKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cached = trackCache.get(overlayMode)
|
||||
|
||||
53
src/components/utils/useAnimationFrameThrottle.test.ts
Normal file
53
src/components/utils/useAnimationFrameThrottle.test.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { useAnimationFrameThrottle } from './useAnimationFrameThrottle'
|
||||
|
||||
describe('useAnimationFrameThrottle', () => {
|
||||
it('schedules callback on next animation frame', () => {
|
||||
const throttle = useAnimationFrameThrottle()
|
||||
const callback = vi.fn()
|
||||
|
||||
throttle.schedule(callback)
|
||||
expect(throttle.isPending()).toBe(true)
|
||||
expect(callback).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('replaces pending callback when scheduled multiple times', () => {
|
||||
const throttle = useAnimationFrameThrottle()
|
||||
const callback1 = vi.fn()
|
||||
const callback2 = vi.fn()
|
||||
|
||||
throttle.schedule(callback1)
|
||||
throttle.schedule(callback2)
|
||||
expect(throttle.isPending()).toBe(true)
|
||||
})
|
||||
|
||||
it('cancels pending callback', () => {
|
||||
const throttle = useAnimationFrameThrottle()
|
||||
const callback = vi.fn()
|
||||
|
||||
throttle.schedule(callback)
|
||||
throttle.cancel()
|
||||
expect(throttle.isPending()).toBe(false)
|
||||
})
|
||||
|
||||
it('flushes callback immediately', () => {
|
||||
const throttle = useAnimationFrameThrottle()
|
||||
const callback = vi.fn(() => 'result')
|
||||
|
||||
throttle.schedule(callback)
|
||||
throttle.flush()
|
||||
expect(callback).toHaveBeenCalled()
|
||||
expect(throttle.isPending()).toBe(false)
|
||||
})
|
||||
|
||||
it('handles flush when no callback is pending', () => {
|
||||
const throttle = useAnimationFrameThrottle()
|
||||
expect(() => throttle.flush()).not.toThrow()
|
||||
})
|
||||
|
||||
it('handles cancel when no callback is pending', () => {
|
||||
const throttle = useAnimationFrameThrottle()
|
||||
expect(() => throttle.cancel()).not.toThrow()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user