From dcc27a88be3aca4f4b47b017d9ae01c35c0c3694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=9D=8E=E5=90=AF=E6=BA=90?= Date: Fri, 24 Jul 2026 16:10:02 +0800 Subject: [PATCH] perf(chart): isolate hover rendering --- package.json | 2 +- src/components/WaveformChart.test.ts | 38 ++++++++ src/components/WaveformChart.vue | 96 ++++++++++--------- src/components/core/types.ts | 12 ++- .../interaction/WaveformHoverHost.vue | 28 ++++++ src/components/interaction/index.ts | 1 + .../rendering/WaveformHoverLayer.vue | 60 ++++++++++++ src/components/rendering/WaveformTrack.vue | 44 +-------- src/components/rendering/index.ts | 1 + 9 files changed, 191 insertions(+), 91 deletions(-) create mode 100644 src/components/interaction/WaveformHoverHost.vue create mode 100644 src/components/rendering/WaveformHoverLayer.vue diff --git a/package.json b/package.json index cdec77a..27e6db1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "waveform-analysis", - "version": "0.1.20", + "version": "0.1.21", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/types/index.d.ts", diff --git a/src/components/WaveformChart.test.ts b/src/components/WaveformChart.test.ts index 42776e1..6c68b8e 100644 --- a/src/components/WaveformChart.test.ts +++ b/src/components/WaveformChart.test.ts @@ -2332,6 +2332,44 @@ describe('WaveformChart', () => { expect(pendingAnimationFrameCount()).toBe(0) }) + it('isolates hover rendering from the chart, track, and waveform path subtrees', async () => { + const wrapper = await mountSizedChart( + { + kind: 'points', + points: [ + { x: 0, y: 0 }, + { x: 1, y: 5 }, + ], + }, + { grid: { rowCount: 1, columnCount: 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 }), + }) + + const track = wrapper.getComponent({ name: 'WaveformTrack' }) + const seriesLayer = wrapper.getComponent({ name: 'WaveformSeriesLayer' }) + const chartUpdate = vi.spyOn(wrapper.vm.$, 'update') + const trackUpdate = vi.spyOn(track.vm.$, 'update') + const seriesLayerUpdate = vi.spyOn(seriesLayer.vm.$, 'update') + const pathBeforeHover = wrapper.get('.waveform-chart__line').element + + overlay.element.dispatchEvent( + new MouseEvent('pointermove', { clientX: overlayWidth, clientY: 120, bubbles: true }), + ) + flushAnimationFrames() + await flushPromises() + + expect(wrapper.get('.waveform-chart__tooltip').text()).toContain('ms: 1,000.0000') + expect(wrapper.findAll('.waveform-chart__crosshair line')).toHaveLength(1) + expect(wrapper.get('.waveform-chart__line').element).toBe(pathBeforeHover) + expect(chartUpdate).not.toHaveBeenCalled() + expect(trackUpdate).not.toHaveBeenCalled() + expect(seriesLayerUpdate).not.toHaveBeenCalled() + }) + it('renders reference grid styling and an optional frame watermark', async () => { const wrapper = await mountSizedChart( { diff --git a/src/components/WaveformChart.vue b/src/components/WaveformChart.vue index 2844349..6c57b33 100644 --- a/src/components/WaveformChart.vue +++ b/src/components/WaveformChart.vue @@ -21,6 +21,7 @@ import { onBeforeUnmount, onMounted, ref, + shallowReactive, shallowRef, watch, type CSSProperties, @@ -58,8 +59,8 @@ import { type AnnotationSeriesInfo, type AnnotationTrackLayout, } from './annotation' -import { WaveformTooltip } from './interaction' -import { WaveformLegend, WaveformTrack } from './rendering' +import { WaveformHoverHost } from './interaction' +import { WaveformHoverLayer, WaveformLegend, WaveformTrack } from './rendering' import { channelColors, margin as chartMargin, @@ -88,7 +89,13 @@ import { X_AXIS_BAND, type WaveformGridOptions, } from './core/grid' -import type { DisplaySeries, DisplayTrack, HoveredSeriesPoint, TrackLayout } from './core/types' +import type { + DisplaySeries, + DisplayTrack, + HoveredSeriesPoint, + TrackLayout, + WaveformHoverState, +} from './core/types' import { buildTrackLayouts, findClosestTrackAtPointer, @@ -192,9 +199,11 @@ const sharedTransform = shallowRef(zoomIdentity) const independentTransforms = shallowRef([]) const sharedYDomains = ref>({}) const independentYDomains = ref>({}) -const hoveredSeriesPoints = ref([]) -const hoveredTrackIndex = ref(null) -const hoverPosition = ref({ x: 0, y: 0 }) +const hoverState = shallowReactive({ + points: [], + trackIndex: null, + position: { x: 0, y: 0 }, +}) const suppressHoverUntilMove = ref(false) const currentPage = ref(1) const resizeObserver = shallowRef() @@ -269,15 +278,6 @@ function handleInteractionKeyUp(event: KeyboardEvent) { } } -// 用于传递给 WaveformTooltip 的接口 -interface TooltipSeriesPoint { - trackIndex: number - name: string - color: string - unit?: string - point: WaveformPoint -} - const fixedWidth = computed(() => Number.isFinite(props.width) ? Math.max(0, props.width ?? 0) : undefined, ) @@ -569,7 +569,6 @@ const yAxisLayout = computed(() => { } }) const hasWaveformData = computed(() => chartSeries.value.length > 0) -const hoveredPoint = computed(() => hoveredSeriesPoints.value[0]?.point ?? null) const hasChartArea = computed(() => innerWidth.value > 0 && innerHeight.value > 0) const resolvedXLabel = computed(() => props.xLabel ?? `时间(${props.timeUnit})`) const activeInteractionMode = computed(() => props.interactionMode) @@ -578,17 +577,6 @@ const isZoomMode = computed( () => activeInteractionMode.value === 'zoom' || activeInteractionMode.value === undefined, ) -// 转换为 Tooltip 组件需要的格式 -const tooltipSeriesPoints = computed(() => { - return hoveredSeriesPoints.value.map((item) => ({ - trackIndex: item.trackIndex, - name: item.name, - color: item.color, - unit: item.unit, - point: item.point, - })) -}) - const sharedXDomain = computed(() => { const values: number[] = [] chartTracks.value.forEach((track) => { @@ -986,9 +974,9 @@ function scheduleHover(update: () => void) { function hoveredPointsMatch(nextPoints: HoveredSeriesPoint[]): boolean { return ( - hoveredSeriesPoints.value.length === nextPoints.length && + hoverState.points.length === nextPoints.length && nextPoints.every((point, index) => { - const current = hoveredSeriesPoints.value[index] + const current = hoverState.points[index] return ( current?.id === point.id && current.trackIndex === point.trackIndex && @@ -1003,20 +991,34 @@ function commitHover( trackIndex: number | null, position: { x: number; y: number }, ) { - if (!hoveredPointsMatch(nextPoints)) hoveredSeriesPoints.value = nextPoints - hoveredTrackIndex.value = trackIndex - hoverPosition.value = position - // Emit using the updated hoveredSeriesPoints to avoid race condition - emit('point-hover', hoveredSeriesPoints.value[0]?.point ?? null) + if (!hoveredPointsMatch(nextPoints)) hoverState.points = nextPoints + hoverState.trackIndex = trackIndex + hoverState.position = position + emit('point-hover', hoverState.points[0]?.point ?? null) } function clearHover() { cancelPendingHover() - hoveredSeriesPoints.value = [] - hoveredTrackIndex.value = null + hoverState.points = [] + hoverState.trackIndex = null emit('point-hover', null) } +function createHoveredSeriesPoint( + series: DisplaySeries, + trackIndex: number, + point: WaveformPoint, +): HoveredSeriesPoint { + return { + id: series.id, + name: series.name, + color: series.color, + unit: series.unit, + trackIndex, + point, + } +} + function beginAnnotationDrag() { suppressHoverUntilMove.value = true clearHover() @@ -1334,7 +1336,7 @@ function handleIndependentPointerMove(event: PointerEvent, trackIndex: number) { const xValue = track.xScale.invert(Math.max(0, Math.min(innerWidth.value, pointerX))) const nextPoints = track.seriesList.flatMap((series) => { const point = nearestPoint(series, xValue) - return point ? [{ ...series, trackIndex, point }] : [] + return point ? [createHoveredSeriesPoint(series, trackIndex, point)] : [] }) commitHover(nextPoints, trackIndex, { x: resolvedChartLeftMargin.value + track.left + pointerX, @@ -1364,7 +1366,7 @@ function handleSharedPointerMove(event: PointerEvent) { const nextPoints = trackLayouts.value.flatMap((track) => track.seriesList.flatMap((series) => { const point = nearestPoint(series, xValue) - return point ? [{ ...series, trackIndex: track.index, point }] : [] + return point ? [createHoveredSeriesPoint(series, track.index, point)] : [] }), ) commitHover(nextPoints, null, { @@ -1939,7 +1941,6 @@ onBeforeUnmount(() => { :track="track" :clip-path-id="clipPathId" :inner-width="innerWidth" - :show-tooltip="showTooltip" :zoomable="zoomable" :display-mode="displayMode" :interaction-mode="activeInteractionMode" @@ -1950,7 +1951,6 @@ onBeforeUnmount(() => { :zero-line="resolvedZeroLine" :time-unit="timeUnit" :y-label="yLabel" - :hovered-point="hoveredSeriesPoints.find((p) => p.trackIndex === track.index)" @pointer-move="handleIndependentPointerMove($event, track.index)" @pointer-down="beginViewportDrag($event, track.index, true)" @pointer-up="finishViewportDrag" @@ -1960,6 +1960,13 @@ onBeforeUnmount(() => { @contextmenu="handleAnnotationContextMenu($event, track.index)" /> + + { @close="annotationInteraction.closeContextMenu" /> - - diff --git a/src/components/core/types.ts b/src/components/core/types.ts index 556a115..ed375bd 100644 --- a/src/components/core/types.ts +++ b/src/components/core/types.ts @@ -63,11 +63,21 @@ export interface WaveformYAxisLayout { /** * 悬浮的系列点 */ -export interface HoveredSeriesPoint extends DisplaySeries { +export interface HoveredSeriesPoint { + id: string + name: string + unit?: string + color: string trackIndex: number point: WaveformPoint } +export interface WaveformHoverState { + points: HoveredSeriesPoint[] + trackIndex: number | null + position: { x: number; y: number } +} + /** * 轨道布局 */ diff --git a/src/components/interaction/WaveformHoverHost.vue b/src/components/interaction/WaveformHoverHost.vue new file mode 100644 index 0000000..1966a9c --- /dev/null +++ b/src/components/interaction/WaveformHoverHost.vue @@ -0,0 +1,28 @@ + + + diff --git a/src/components/interaction/index.ts b/src/components/interaction/index.ts index 0a0df2e..d075827 100644 --- a/src/components/interaction/index.ts +++ b/src/components/interaction/index.ts @@ -1 +1,2 @@ export { default as WaveformTooltip } from './WaveformTooltip.vue' +export { default as WaveformHoverHost } from './WaveformHoverHost.vue' diff --git a/src/components/rendering/WaveformHoverLayer.vue b/src/components/rendering/WaveformHoverLayer.vue new file mode 100644 index 0000000..15b4ef9 --- /dev/null +++ b/src/components/rendering/WaveformHoverLayer.vue @@ -0,0 +1,60 @@ + + + + + diff --git a/src/components/rendering/WaveformTrack.vue b/src/components/rendering/WaveformTrack.vue index c4690c9..3e73cc6 100644 --- a/src/components/rendering/WaveformTrack.vue +++ b/src/components/rendering/WaveformTrack.vue @@ -4,12 +4,7 @@ import { axisBottom, axisLeft, axisRight, select } from 'd3' import { formatAxisTime, formatScientificAxisLabel } from '../../utils' import type { WaveformAxesOptions, WaveformFrameStyle, WaveformZeroLineOptions } from '../../types' import type { WaveformDisplayMode, WaveformInteractionMode } from '../data/types' -import type { - DisplaySeries, - HoveredSeriesPoint, - TrackLayout, - WaveformYAxisLayout, -} from '../core/types' +import type { DisplaySeries, TrackLayout, WaveformYAxisLayout } from '../core/types' import WaveformSeriesLayer from './WaveformSeriesLayer.vue' interface Props { @@ -19,8 +14,6 @@ interface Props { clipPathId: string /** 内部宽度 */ innerWidth: number - /** 是否显示 tooltip */ - showTooltip: boolean /** 是否可缩放 */ zoomable: boolean /** 显示模式 */ @@ -35,8 +28,6 @@ interface Props { axes?: WaveformAxesOptions /** 时间单位 */ timeUnit: 's' | 'ms' - /** 悬浮点(用于显示十字线) */ - hoveredPoint?: HoveredSeriesPoint /** Y 轴标签回退值 */ yLabel?: string /** Hide visual aids while keeping chart interaction active. */ @@ -110,20 +101,6 @@ function shouldShowYAxisLabel(trackHeight: number, trackIndex: number): boolean return trackIndex % labelSpacing === 0 } -function crosshairX(): number { - return props.hoveredPoint && props.hoveredPoint.trackIndex === props.track.index - ? props.track.xScale(props.hoveredPoint.point.x) - : 0 -} - -function hasCrosshair(): boolean { - return ( - props.showTooltip && - props.hoveredPoint !== undefined && - props.hoveredPoint.trackIndex === props.track.index - ) -} - function zeroLineY(axis: WaveformYAxisLayout): number | null { const [minimum, maximum] = axis.scale.domain() if (!props.zeroLine.visible || minimum > 0 || maximum < 0) return null @@ -472,15 +449,6 @@ watch( - - - - -