feat: 优化波形渲染性能与交互
All checks were successful
Package component / package (push) Successful in 5m56s

This commit is contained in:
李启源
2026-07-22 16:36:48 +08:00
parent 356f55c9fd
commit 5b0ba7413e
35 changed files with 8208 additions and 246 deletions

View File

@@ -4,7 +4,11 @@ import { describe, expect, it, vi } from 'vitest'
import { flushAnimationFrames, pendingAnimationFrameCount, resizeObservers } from '../test/setup'
import WaveformChart from './WaveformChart.vue'
import { prepareWaveformSeries } from './core/useWaveformData'
import { waveformLegendErrorBarPath, waveformLegendLinePath } from './rendering/seriesStyle'
import {
waveformLegendErrorBarPath,
waveformLegendLinePath,
waveformLineDasharray,
} from './rendering/seriesStyle'
import { normalizeWaveformData, normalizeWaveformSeries, type WaveformData } from './waveform'
async function mountSizedChart(data: WaveformData, extraProps = {}) {
@@ -52,6 +56,29 @@ describe('normalizeWaveformData', () => {
expect(normalizeWaveformData({ kind: 'samples', values: [1, 2], sampleRate: 0 })).toEqual([])
})
it('keeps large-data error extrema in the prepared Y domain', () => {
const points = Array.from({ length: 10_001 }, (_, index) => ({
x: index,
y: 0,
...(index === 5_555 ? { error: 10_000 } : {}),
}))
const [series] = prepareWaveformSeries({
kind: 'series',
series: [
{
name: 'errors',
errorBar: { visible: true },
data: { kind: 'points', points },
},
],
})
expect(series?.points).toHaveLength(points.length)
expect(series?.hasErrorPoints).toBe(true)
expect(series?.yDomain[0]).toBeLessThanOrEqual(-10_000)
expect(series?.yDomain[1]).toBeGreaterThanOrEqual(10_000)
})
it('normalizes errors and preserves a pure error-bar series', () => {
const [series] = normalizeWaveformSeries({
kind: 'series',
@@ -152,6 +179,7 @@ describe('normalizeWaveformData', () => {
unit: 'T',
color: undefined,
lineType: 'linear',
lineStyle: 'solid',
pointType: 'none',
errorBar: { visible: false, width: 1.5, capWidth: 8 },
points: [{ x: 1, y: 2 }],
@@ -170,6 +198,9 @@ describe('legend series geometry', () => {
expect(waveformLegendLinePath('none')).toBeNull()
expect(waveformLegendErrorBarPath(10)).toBe('M8 2H18M13 2V14M8 14H18')
expect(waveformLegendErrorBarPath(100)).toBe('M1 2H25M13 2V14M1 14H25')
expect(waveformLineDasharray('solid')).toBeUndefined()
expect(waveformLineDasharray('dashed')).toBe('8 5')
expect(waveformLineDasharray('dash-dot')).toBe('8 5 1.5 5')
})
})
@@ -221,6 +252,26 @@ describe('WaveformChart', () => {
second.unmount()
})
it('does not reuse Y scales between chart instances with the same default series ID', async () => {
const first = await mountSizedChart({
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
],
})
const second = await mountSizedChart({
kind: 'points',
points: [
{ x: 0, y: 10_000 },
{ x: 1, y: 20_000 },
],
})
expect(first.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
expect(second.get('.waveform-chart__axis-exponent--y').text()).toBe('E+04')
})
it('renders a configurable zero line only when the Y domain contains zero', async () => {
const wrapper = await mountSizedChart(
{
@@ -478,6 +529,7 @@ describe('WaveformChart', () => {
trackId: 'styled-track',
name: '纯线',
lineType: 'linear',
lineStyle: 'dashed',
pointType: 'none',
data: {
kind: 'points',
@@ -492,6 +544,7 @@ describe('WaveformChart', () => {
trackId: 'styled-track',
name: '阶梯误差',
lineType: 'step-after',
lineStyle: 'dash-dot',
pointType: 'circle',
errorBar: { visible: true, color: '#222222', width: 2, capWidth: 10 },
data: {
@@ -525,6 +578,13 @@ describe('WaveformChart', () => {
)
const stepLine = wrapper.get('.waveform-chart__line[data-series-id="step-errors"]')
expect(stepLine.attributes('data-line-type')).toBe('step-after')
expect(stepLine.attributes('data-line-style')).toBe('dash-dot')
expect(stepLine.attributes('stroke-dasharray')).toBe('8 5 1.5 5')
expect(
wrapper
.get('.waveform-chart__line[data-series-id="line-only"]')
.attributes('stroke-dasharray'),
).toBe('8 5')
expect(stepLine.attributes('d')).toMatch(/^M[\d.-]+,([\d.-]+)L[\d.-]+,\1L/)
expect(
wrapper
@@ -556,6 +616,16 @@ describe('WaveformChart', () => {
'none',
])
expect(swatches[0]?.attributes('data-error-bar-visible')).toBe('true')
expect(swatches.map((swatch) => swatch.attributes('data-line-style'))).toEqual([
'solid',
'dashed',
'dash-dot',
'solid',
])
expect(swatches[1]?.get('.waveform-legend__line').attributes('stroke-dasharray')).toBe('8 5')
expect(swatches[2]?.get('.waveform-legend__line').attributes('stroke-dasharray')).toBe(
'8 5 1.5 5',
)
expect(swatches[2]?.attributes('data-error-bar-visible')).toBe('true')
expect(swatches[3]?.attributes('data-error-bar-visible')).toBe('true')
expect(swatches[0]!.findAll('path').map((path) => path.classes())).toEqual([
@@ -2054,6 +2124,38 @@ describe('WaveformChart', () => {
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null])
})
it('hides the numeric tooltip and crosshair when showTooltip is disabled', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 5 },
],
},
{ grid: { rowCount: 1, columnCount: 1 }, showTooltip: false },
)
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 MouseEvent('pointermove', { clientX: 700, clientY: 120, bubbles: true }),
)
flushAnimationFrames()
await flushPromises()
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__crosshair').exists()).toBe(false)
await wrapper.setProps({ showTooltip: true })
await flushPromises()
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
expect(wrapper.find('.waveform-chart__crosshair').exists()).toBe(true)
})
it('coalesces pointer moves per frame and cancels pending hover work', async () => {
const wrapper = await mountSizedChart(
{
@@ -2517,6 +2619,139 @@ describe('WaveformChart', () => {
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(false)
})
it('enables space-drag panning only when pannable is true and the pointer is inside', async () => {
const data: WaveformData = {
kind: 'points',
points: Array.from({ length: 5 }, (_, index) => ({ x: index, y: index })),
}
const disabled = await mountSizedChart(data)
const disabledOverlay = disabled.get('.waveform-chart__overlay--independent')
const disabledWidth = Number(disabledOverlay.attributes('width'))
const disabledHeight = Number(disabledOverlay.attributes('height'))
Object.defineProperty(disabledOverlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width: disabledWidth, height: disabledHeight }),
})
await disabled.trigger('pointerenter')
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', cancelable: true }))
const disabledDown = new MouseEvent('pointerdown', {
button: 0,
clientX: disabledWidth * 0.25,
clientY: disabledHeight / 2,
bubbles: true,
})
Object.defineProperty(disabledDown, 'pointerId', { value: 31 })
disabledOverlay.element.dispatchEvent(disabledDown)
const disabledMove = new MouseEvent('pointermove', {
clientX: disabledWidth * 0.75,
clientY: disabledHeight / 2,
bubbles: true,
})
Object.defineProperty(disabledMove, 'pointerId', { value: 31 })
disabledOverlay.element.dispatchEvent(disabledMove)
await flushPromises()
expect(disabled.find('.waveform-chart__zoom-selection').exists()).toBe(true)
const enabled = await mountSizedChart(data, { pannable: true })
const enabledOverlay = enabled.get('.waveform-chart__overlay--independent')
const enabledWidth = Number(enabledOverlay.attributes('width'))
const enabledHeight = Number(enabledOverlay.attributes('height'))
Object.defineProperty(enabledOverlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width: enabledWidth, height: enabledHeight }),
})
const boxDown = new MouseEvent('pointerdown', {
button: 0,
clientX: enabledWidth * 0.25,
clientY: enabledHeight / 2,
bubbles: true,
})
Object.defineProperty(boxDown, 'pointerId', { value: 30 })
enabledOverlay.element.dispatchEvent(boxDown)
const boxMove = new MouseEvent('pointermove', {
clientX: enabledWidth * 0.75,
clientY: enabledHeight / 2,
bubbles: true,
})
Object.defineProperty(boxMove, 'pointerId', { value: 30 })
enabledOverlay.element.dispatchEvent(boxMove)
const boxUp = new MouseEvent('pointerup', {
clientX: enabledWidth * 0.75,
clientY: enabledHeight / 2,
bubbles: true,
})
Object.defineProperty(boxUp, 'pointerId', { value: 30 })
enabledOverlay.element.dispatchEvent(boxUp)
await flushPromises()
const startBeforePan = enabled.get('.waveform-chart__axis-endpoint--start').text()
await enabled.trigger('pointerenter')
const spaceDown = new KeyboardEvent('keydown', { code: 'Space', cancelable: true })
window.dispatchEvent(spaceDown)
const enabledDown = new MouseEvent('pointerdown', {
button: 0,
clientX: enabledWidth / 2,
clientY: enabledHeight / 2,
bubbles: true,
})
Object.defineProperty(enabledDown, 'pointerId', { value: 32 })
enabledOverlay.element.dispatchEvent(enabledDown)
const enabledMove = new MouseEvent('pointermove', {
clientX: enabledWidth / 2 + 20,
clientY: enabledHeight / 2,
bubbles: true,
})
Object.defineProperty(enabledMove, 'pointerId', { value: 32 })
enabledOverlay.element.dispatchEvent(enabledMove)
await flushPromises()
expect(spaceDown.defaultPrevented).toBe(true)
expect(enabled.classes()).toContain('waveform-chart--panning')
expect(enabled.find('.waveform-chart__zoom-selection').exists()).toBe(false)
expect(enabled.get('.waveform-chart__axis-endpoint--start').text()).not.toBe(startBeforePan)
window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' }))
})
it('does not activate pannable on a chart that the pointer is outside', async () => {
const data: WaveformData = {
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
],
}
const active = await mountSizedChart(data, { pannable: true })
const inactive = await mountSizedChart(data, { pannable: true })
await active.trigger('pointerenter')
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', cancelable: true }))
const inactiveOverlay = inactive.get('.waveform-chart__overlay--independent')
const width = Number(inactiveOverlay.attributes('width'))
const height = Number(inactiveOverlay.attributes('height'))
Object.defineProperty(inactiveOverlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height }),
})
const down = new MouseEvent('pointerdown', {
button: 0,
clientX: width * 0.25,
clientY: height / 2,
bubbles: true,
})
Object.defineProperty(down, 'pointerId', { value: 33 })
inactiveOverlay.element.dispatchEvent(down)
const move = new MouseEvent('pointermove', {
clientX: width * 0.75,
clientY: height / 2,
bubbles: true,
})
Object.defineProperty(move, 'pointerId', { value: 33 })
inactiveOverlay.element.dispatchEvent(move)
await flushPromises()
expect(inactive.find('.waveform-chart__zoom-selection').exists()).toBe(true)
expect(inactive.classes()).not.toContain('waveform-chart--panning')
window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' }))
})
it('limits box zoom to the configured minimum x span', async () => {
const wrapper = await mountSizedChart(
{

View File

@@ -63,6 +63,19 @@ import {
channelColors,
margin as chartMargin,
minimumHeight as chartMinimumHeight,
Y_AXIS_CHARACTER_WIDTH,
Y_AXIS_TICK_PADDING,
Y_AXIS_OUTER_PADDING,
Y_AXIS_LABEL_GAP,
Y_AXIS_LABEL_BAND_WIDTH,
MINIMUM_PLOT_WIDTH,
WHEEL_ZOOM_DEBOUNCE_MS,
MINIMUM_SELECTION_SIZE,
ZOOM_CONSTRAINTS,
TITLE_DEFAULT_FONT_SIZE,
TITLE_CHAR_WIDTH_RATIO,
TITLE_LINE_HEIGHT,
ZERO_LINE_DEFAULTS,
} from './core/constants'
import {
getGridGap,
@@ -75,12 +88,19 @@ 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,
findClosestTrackAtPointer,
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'
import { useWaveformInstanceId } from '../utils/waveformId'
const xPointBisector = bisector<WaveformPoint, number>((point) => point.x)
const props = withDefaults(
defineProps<{
data: WaveformData
@@ -93,6 +113,7 @@ const props = withDefaults(
lineColor?: string
showTooltip?: boolean
zoomable?: boolean
pannable?: boolean
minZoomSpan?: number
minVisiblePoints?: number
initialXDomain?: [number, number]
@@ -119,6 +140,7 @@ const props = withDefaults(
lineColor: '#0960bd',
showTooltip: true,
zoomable: true,
pannable: false,
minVisiblePoints: 0,
timeUnit: 'ms',
frameNumber: undefined,
@@ -191,7 +213,7 @@ const lastIndependentZoomGestures = new Map<number, ZoomGestureKind>()
const lastZoomedTrackIndexes = new Set<number>()
const zoomThrottle = useAnimationFrameThrottle()
const hoverThrottle = useAnimationFrameThrottle()
const wheelZoomDebounceMs = 200
const wheelZoomDebounceMs = WHEEL_ZOOM_DEBOUNCE_MS
let wheelZoomEndTimer: ReturnType<typeof setTimeout> | undefined
const preparedSeries = usePreparedWaveformSeries(() => props.data, handleDataReferenceChange)
@@ -211,6 +233,7 @@ interface SelectionState {
const selection = ref<SelectionState | null>(null)
const spacePressed = ref(false)
const pointerInsideChart = ref(false)
const selectionBox = computed(() => {
const active = selection.value
if (!active) return null
@@ -224,11 +247,22 @@ const selectionBox = computed(() => {
})
function handleInteractionKeyDown(event: KeyboardEvent) {
if (event.code === 'Space') spacePressed.value = true
if (event.code !== 'Space' || !props.pannable || !pointerInsideChart.value) return
const target = event.target
if (
target instanceof Element &&
target.closest('button, input, select, textarea, [contenteditable]:not([contenteditable="false"])')
) {
return
}
spacePressed.value = true
event.preventDefault()
}
function handleInteractionKeyUp(event: KeyboardEvent) {
if (event.code === 'Space') spacePressed.value = false
if (event.code === 'Space') {
spacePressed.value = false
}
}
// 用于传递给 WaveformTooltip 的接口
@@ -262,9 +296,9 @@ const resolvedZeroLine = computed(() => {
const width = props.zeroLine?.width
return {
visible: props.zeroLine?.visible === true,
color: props.zeroLine?.color || '#98a2b3',
width: typeof width === 'number' && Number.isFinite(width) && width > 0 ? width : 1,
dash: props.zeroLine?.dash ?? '6 4',
color: props.zeroLine?.color || ZERO_LINE_DEFAULTS.COLOR,
width: typeof width === 'number' && Number.isFinite(width) && width > 0 ? width : ZERO_LINE_DEFAULTS.WIDTH,
dash: props.zeroLine?.dash ?? ZERO_LINE_DEFAULTS.DASH,
}
})
const legendBackgroundColor = computed(
@@ -292,7 +326,7 @@ const titleAreaReserved = computed(
const titleVisible = computed(() => titleAreaReserved.value && !isCleanView.value)
const titleFontSize = computed(() => {
const fontSize = props.title?.textStyle?.fontSize
return Number.isFinite(fontSize) && (fontSize ?? 0) > 0 ? (fontSize as number) : 14
return Number.isFinite(fontSize) && (fontSize ?? 0) > 0 ? (fontSize as number) : TITLE_DEFAULT_FONT_SIZE
})
const titleRotation = computed(() => {
const rotation = props.title?.textStyle?.rotation
@@ -310,14 +344,14 @@ const titlePresentationStyle = computed<CSSProperties>(() => ({
fontStyle: props.title?.textStyle?.fontStyle ?? 'normal',
textDecoration: props.title?.textStyle?.textDecoration ?? 'none',
letterSpacing: props.title?.textStyle?.letterSpacing ?? 'normal',
lineHeight: '1.2',
lineHeight: String(TITLE_LINE_HEIGHT),
}))
const estimatedTitleWidth = computed(() => {
const letterSpacing = Number.parseFloat(props.title?.textStyle?.letterSpacing ?? '')
const spacingWidth = Number.isFinite(letterSpacing)
? Math.max(0, resolvedTitleText.value.length - 1) * letterSpacing
: 0
return Math.max(1, resolvedTitleText.value.length * titleFontSize.value * 0.62 + spacingWidth)
return Math.max(1, resolvedTitleText.value.length * titleFontSize.value * TITLE_CHAR_WIDTH_RATIO + spacingWidth)
})
const titleAvailableWidth = computed(() => {
const measuredAvailableWidth = chartWidth.value - TITLE_AREA_HORIZONTAL_PADDING * 2
@@ -333,7 +367,7 @@ const titleMeasureStyle = computed<CSSProperties>(() => ({
const titleLayout = computed(() =>
calculateRotatedTitleLayout({
naturalWidth: measuredTitleWidth.value || estimatedTitleWidth.value,
naturalHeight: measuredTitleHeight.value || titleFontSize.value * 1.2,
naturalHeight: measuredTitleHeight.value || titleFontSize.value * TITLE_LINE_HEIGHT,
availableWidth: titleAvailableWidth.value,
rotation: titleRotation.value,
}),
@@ -381,12 +415,18 @@ const chartTracks = computed<DisplayTrack[]>(() => {
})
return Array.from(groupedSeries, ([id, series]) => {
const visibleSeries = series.filter((item) => !hiddenSeriesIdSet.value.has(item.id))
const xDomainValues: number[] = []
const yDomainValues: number[] = []
visibleSeries.forEach((item) => {
xDomainValues.push(item.xDomain[0], item.xDomain[1])
yDomainValues.push(item.yDomain[0], item.yDomain[1])
})
return {
id,
series,
visibleSeries,
xDomain: paddedDomain(visibleSeries.flatMap((item) => item.xDomain)),
yDomain: paddedDomain(visibleSeries.flatMap((item) => item.yDomain)),
xDomain: paddedDomain(xDomainValues),
yDomain: paddedDomain(yDomainValues),
}
})
})
@@ -397,12 +437,13 @@ const pagedTracks = computed(() =>
paginateSeries(chartTracks.value, currentPage.value, gridOptions.value),
)
const yAxisCharacterWidth = 7
const yAxisTickPadding = 7
const yAxisOuterPadding = 4
const yAxisLabelGap = 6
const yAxisLabelBandWidth = 24
const minimumPlotWidth = 120
// 使用从常量文件导入的值
const yAxisCharacterWidth = Y_AXIS_CHARACTER_WIDTH
const yAxisTickPadding = Y_AXIS_TICK_PADDING
const yAxisOuterPadding = Y_AXIS_OUTER_PADDING
const yAxisLabelGap = Y_AXIS_LABEL_GAP
const yAxisLabelBandWidth = Y_AXIS_LABEL_BAND_WIDTH
const minimumPlotWidth = MINIMUM_PLOT_WIDTH
const yAxisMetrics = computed(() => {
const axisText = chartTracks.value
@@ -533,11 +574,13 @@ const tooltipSeriesPoints = computed<TooltipSeriesPoint[]>(() => {
}))
})
const sharedXDomain = computed(() =>
paddedDomain(
chartTracks.value.flatMap((track) => (track.visibleSeries.length ? track.xDomain : [])),
),
)
const sharedXDomain = computed(() => {
const values: number[] = []
chartTracks.value.forEach((track) => {
if (track.visibleSeries.length) values.push(track.xDomain[0], track.xDomain[1])
})
return paddedDomain(values)
})
const initialXDomain = computed<[number, number]>(() => {
const domain = props.initialXDomain
if (
@@ -820,10 +863,10 @@ function clearZoomBindings() {
function resolveMaximumZoomScale(domain: [number, number]): number {
const minZoomSpan = props.minZoomSpan
if (!Number.isFinite(minZoomSpan) || (minZoomSpan ?? 0) <= 0) return 40
if (!Number.isFinite(minZoomSpan) || (minZoomSpan ?? 0) <= 0) return ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE
const domainSpan = Math.abs(domain[1] - domain[0])
if (!Number.isFinite(domainSpan) || domainSpan <= 0) return 1
return Math.min(40, Math.max(1, domainSpan / (minZoomSpan ?? domainSpan)))
if (!Number.isFinite(domainSpan) || domainSpan <= 0) return ZOOM_CONSTRAINTS.MIN_SCALE
return Math.min(ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE, Math.max(ZOOM_CONSTRAINTS.MIN_SCALE, domainSpan / (minZoomSpan ?? domainSpan)))
}
function canZoomTrack(track: TrackLayout): boolean {
@@ -978,7 +1021,7 @@ function consumeHoverSuppression(): boolean {
}
function nearestPoint(series: DisplaySeries, xValue: number): WaveformPoint | undefined {
const index = bisector((point: WaveformPoint) => point.x).center(series.points, xValue)
const index = xPointBisector.center(series.points, xValue)
return series.points[index]
}
@@ -1092,32 +1135,7 @@ function resolveTrackAtPointer(
return track?.hasVisibleSeries ? track : undefined
}
const visibleTracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
if (!visibleTracks.length) return undefined
const distanceToTrack = (track: TrackLayout) => {
const xDistance =
pointerX < track.left
? track.left - pointerX
: pointerX > track.left + track.width
? pointerX - track.left - track.width
: 0
if (pointerY < track.top) return track.top - pointerY
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 = 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))
return centerDistance < closestCenterDistance ? candidate : closest
})
return findClosestTrackAtPointer(visibleTracks, pointerX, pointerY)
}
function resolveAnnotationCandidates(
@@ -1337,7 +1355,7 @@ function handleSharedPointerMove(event: PointerEvent) {
})
}
const minimumSelectionSize = 6
const minimumSelectionSize = MINIMUM_SELECTION_SIZE
function transformForDomain(
domain: [number, number],
@@ -1403,7 +1421,8 @@ function currentYDomains(): Record<string, [number, number]> {
}
function beginViewportDrag(event: PointerEvent, trackIndex: number, independent: boolean) {
if (!props.zoomable || !isZoomMode.value || event.button !== 0) return
const panRequested = props.pannable && spacePressed.value
if ((!props.zoomable && !panRequested) || !isZoomMode.value || event.button !== 0) return
const overlay = event.currentTarget as SVGRectElement
const track = trackLayouts.value.find((item) => item.index === trackIndex)
if (!track) return
@@ -1419,7 +1438,7 @@ function beginViewportDrag(event: PointerEvent, trackIndex: number, independent:
currentX: x,
currentY: y,
pointerId: event.pointerId,
mode: spacePressed.value ? 'pan' : 'box',
mode: panRequested ? 'pan' : 'box',
xDomain: track.xScale.domain() as [number, number],
yDomains: currentYDomains(),
}
@@ -1804,6 +1823,8 @@ onBeforeUnmount(() => {
:data-overlay-mode="overlayMode"
:data-chart-left-margin="resolvedChartLeftMargin"
:data-title-area-height="titleAreaHeight"
@pointerenter="pointerInsideChart = true"
@pointerleave="pointerInsideChart = false"
@contextmenu.capture="handleNativeContextMenu"
>
<div

View File

@@ -1,8 +1,129 @@
/**
* 核心常量定义
* 波形图表核心常量配置
*/
/** 通道颜色 */
// ==================== 布局常量 ====================
/** 图表边距 */
export const margin = { top: 18, right: 24, bottom: 52, left: 64 }
/**
* 图表最小高度(像素)
*/
export const minimumHeight = 180
/**
* 网格间距配置
*/
export const gridGap = {
independent: 30,
separated: 20,
compact: 20,
}
// ==================== Y轴常量 ====================
/**
* Y轴字符宽度像素
*/
export const Y_AXIS_CHARACTER_WIDTH = 7
/**
* Y轴刻度内边距像素
*/
export const Y_AXIS_TICK_PADDING = 7
/**
* Y轴外边距像素
*/
export const Y_AXIS_OUTER_PADDING = 4
/**
* Y轴标签间距像素
*/
export const Y_AXIS_LABEL_GAP = 6
/**
* Y轴标签带宽度像素
*/
export const Y_AXIS_LABEL_BAND_WIDTH = 24
/**
* Y轴指数标签间距像素
*/
export const Y_AXIS_EXPONENT_GAP = 8
/**
* 最小绘图宽度(像素)
*/
export const MINIMUM_PLOT_WIDTH = 120
// ==================== 交互常量 ====================
/**
* 滚轮缩放防抖时间(毫秒)
*/
export const WHEEL_ZOOM_DEBOUNCE_MS = 200
/**
* 最小选择框尺寸(像素)
*/
export const MINIMUM_SELECTION_SIZE = 6
/**
* 缩放限制常量
*/
export const ZOOM_CONSTRAINTS = {
/** 默认最大缩放倍数 */
DEFAULT_MAX_SCALE: 40,
/** 最小缩放倍数 */
MIN_SCALE: 1,
}
/**
* 悬停检测阈值(像素)
* 当指针移动距离小于此值时,使用缓存的悬停结果
*/
// ==================== 注释常量 ====================
/**
* 注释命中半径(像素)
*/
export const ANNOTATION_HIT_RADIUS = 8
/**
* 注释歧义距离(像素)
* 当多个候选注释点距离差小于此值时,视为歧义
*/
export const ANNOTATION_AMBIGUITY_DISTANCE = 3
// ==================== 标题常量 ====================
/**
* 标题区域水平内边距(像素)
*/
export const TITLE_AREA_HORIZONTAL_PADDING = 24
/**
* 标题默认字体大小(像素)
*/
export const TITLE_DEFAULT_FONT_SIZE = 14
/**
* 标题默认字符宽度系数
*/
export const TITLE_CHAR_WIDTH_RATIO = 0.62
/**
* 标题行高
*/
export const TITLE_LINE_HEIGHT = 1.2
// ==================== 样式常量 ====================
/**
* 通道默认颜色列表
*/
export const channelColors = [
'#0960bd',
'#ff7f0e',
@@ -16,8 +137,56 @@ export const channelColors = [
'#1d39c4',
]
/** 图表边距 */
export const margin = { top: 18, right: 24, bottom: 52, left: 64 }
/**
* 错误条默认配置
*/
export const ERROR_BAR_DEFAULTS = {
/** 线宽(像素) */
WIDTH: 1.5,
/** 端帽宽度(像素) */
CAP_WIDTH: 8,
}
/** 最小高度 */
export const minimumHeight = 180
/**
* 零线默认配置
*/
export const ZERO_LINE_DEFAULTS = {
/** 颜色 */
COLOR: '#98a2b3',
/** 线宽(像素) */
WIDTH: 1,
/** 虚线样式 */
DASH: '6 4',
}
/**
* 图例默认配置
*/
export const LEGEND_DEFAULTS = {
/** 背景色 */
BACKGROUND_COLOR: 'rgba(255, 255, 255, 0.7)',
/** 位置 */
POSITION: 'top-right' as const,
/** 方向 */
ORIENTATION: 'auto' as const,
}
// ==================== 渲染常量 ====================
/**
* 最大多轴数量
*/
export const MAX_MULTI_Y_AXIS_COUNT = 4
/**
* 缓存限制
*/
export const CACHE_LIMITS = {
/** Y轴组缓存最大条目数 */
Y_AXIS_GROUPS: 100,
/** 轨道距离缓存刷新阈值(像素) */
TRACK_DISTANCE_REFRESH_THRESHOLD: 5,
}
// 向后兼容性导出
export { channelColors as default }

View File

@@ -39,6 +39,31 @@ describe('waveform grid helpers', () => {
})
})
it('preserves optional per-direction grid colors and ignores blank values', () => {
expect(
normalizeGridOptions({
trackLines: {
voltage: {
horizontalColor: '#ef4444',
verticalColor: ' #2563eb ',
},
current: { horizontalColor: ' ' },
},
}).trackLines,
).toEqual({
voltage: {
horizontal: true,
vertical: true,
horizontalColor: '#ef4444',
verticalColor: ' #2563eb ',
},
current: {
horizontal: true,
vertical: true,
},
})
})
it('falls back to visible grid lines for invalid runtime values', () => {
const options = {
trackLines: {

View File

@@ -14,6 +14,10 @@ export interface WaveformGridOptions {
export interface WaveformGridLineOptions {
horizontal?: boolean
vertical?: boolean
/** Optional stroke color for horizontal major and minor grid lines. */
horizontalColor?: string
/** Optional stroke color for vertical major and minor grid lines. */
verticalColor?: string
}
export type WaveformGridTrackLines = Record<string, WaveformGridLineOptions>
@@ -21,6 +25,8 @@ export type WaveformGridTrackLines = Record<string, WaveformGridLineOptions>
export interface NormalizedWaveformGridLineOptions {
horizontal: boolean
vertical: boolean
horizontalColor?: string
verticalColor?: string
}
export interface NormalizedWaveformGridOptions {
@@ -57,6 +63,14 @@ export function normalizeGridOptions(options?: WaveformGridOptions): NormalizedW
{
horizontal: typeof lines?.horizontal === 'boolean' ? lines.horizontal : true,
vertical: typeof lines?.vertical === 'boolean' ? lines.vertical : true,
horizontalColor:
typeof lines?.horizontalColor === 'string' && lines.horizontalColor.trim()
? lines.horizontalColor
: undefined,
verticalColor:
typeof lines?.verticalColor === 'string' && lines.verticalColor.trim()
? lines.verticalColor
: undefined,
},
]),
)

View File

@@ -1,5 +1,7 @@
export * from './constants'
export * from './grid'
export * from './layout'
export * from './types'
export * from './useWaveformData'
// 从 layout 选择性导出,避免重复导出 constants
export { buildTrackLayouts, measureTrackYAxisClearance, buildYAxisSeriesGroups } from './layout'
// 从 constants 统一导出所有常量
export * from './constants'

View File

@@ -6,6 +6,7 @@ import type { DisplaySeries, DisplayTrack } from './types'
import {
buildTrackLayouts,
buildYAxisSeriesGroups,
findClosestTrackAtPointer,
MAX_MULTI_Y_AXIS_COUNT,
measureYAxisGroupClearance,
} from './layout'
@@ -16,6 +17,7 @@ function series(id: string, minimum: number, maximum: number): DisplaySeries {
name: id,
color: '#1677ff',
lineType: 'linear',
lineStyle: 'solid',
pointType: 'none',
errorBar: { visible: false, width: 1.5, capWidth: 8 },
points: [
@@ -77,6 +79,14 @@ function layoutForSeries(
}
describe('multi-value Y-axis grouping', () => {
it('rebuilds Y scales when the same track ID receives a different domain', () => {
const first = layoutForSeries(series('shared', 0, 1))
const second = layoutForSeries(series('shared', 10_000, 20_000))
expect(first.yScale.domain()).toEqual([0, 1])
expect(second.yScale.domain()).toEqual([10_000, 20_000])
})
it('uses a configured visible Y domain for axis and series scales', () => {
const source = series('a', 0, 100)
const sourceTrack = track([source])
@@ -231,6 +241,18 @@ describe('multi-value Y-axis grouping', () => {
})
})
describe('track hit testing', () => {
const tracks = [
{ id: 'first', left: 0, top: 0, width: 100, height: 40 },
{ id: 'second', left: 0, top: 50, width: 100, height: 40 },
]
it('switches tracks immediately across a boundary less than five pixels apart', () => {
expect(findClosestTrackAtPointer(tracks, 50, 44)?.id).toBe('first')
expect(findClosestTrackAtPointer(tracks, 50, 46)?.id).toBe('second')
})
})
describe('decoration sampling', () => {
const denseSeries = (): DisplaySeries => ({
...series('dense', -1, 1),

View File

@@ -27,14 +27,16 @@ import {
type NormalizedWaveformGridOptions,
} from './grid'
import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
import { MAX_MULTI_Y_AXIS_COUNT, Y_AXIS_EXPONENT_GAP } from './constants'
// 导出常量供外部使用
export { MAX_MULTI_Y_AXIS_COUNT, Y_AXIS_EXPONENT_GAP } from './constants'
export const MAX_MULTI_Y_AXIS_COUNT = 4
const Y_AXIS_CHARACTER_WIDTH = 7
const Y_AXIS_TICK_PADDING = 7
const Y_AXIS_OUTER_PADDING = 4
const Y_AXIS_LABEL_GAP = 6
const Y_AXIS_LABEL_BAND_WIDTH = 24
export const Y_AXIS_EXPONENT_GAP = 8
interface YAxisSeriesGroup {
index: number
@@ -50,39 +52,17 @@ function resolveAxisSides(axisCount: number): Array<'left' | 'right'> {
return ['left']
}
// 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,
]),
])
}
// 使用 WeakMap 进行缓存优化,避免手动清理
const yAxisGroupsCache = new WeakMap<DisplayTrack, Map<WaveformOverlayMode, YAxisSeriesGroup[]>>()
export function buildYAxisSeriesGroups(
track: DisplayTrack,
overlayMode: WaveformOverlayMode,
): YAxisSeriesGroup[] {
const cacheKey = getCacheKey(track)
let trackCache = yAxisGroupsCache.get(cacheKey)
let trackCache = yAxisGroupsCache.get(track)
if (!trackCache) {
trackCache = new Map()
yAxisGroupsCache.set(cacheKey, trackCache)
if (yAxisGroupsCache.size > MAX_CACHE_SIZE) {
const firstKey = yAxisGroupsCache.keys().next().value
if (firstKey !== undefined) {
yAxisGroupsCache.delete(firstKey)
}
}
yAxisGroupsCache.set(track, trackCache)
}
const cached = trackCache.get(overlayMode)
@@ -181,6 +161,49 @@ interface SeriesGridCell extends GridCellGeometry {
series?: DisplayTrack
}
type PositionedTrack = Pick<TrackLayout, 'left' | 'top' | 'width' | 'height'>
export function findClosestTrackAtPointer<T extends PositionedTrack>(
tracks: readonly T[],
pointerX: number,
pointerY: number,
): T | undefined {
const distanceToTrack = (track: T) => {
const xDistance =
pointerX < track.left
? track.left - pointerX
: pointerX > track.left + track.width
? pointerX - track.left - track.width
: 0
return pointerY < track.top
? track.top - pointerY
: pointerY > track.top + track.height
? pointerY - (track.top + track.height)
: xDistance
}
let closestTrack = tracks[0]
if (!closestTrack) return undefined
let closestDistance = distanceToTrack(closestTrack)
for (let index = 1; index < tracks.length; index += 1) {
const candidate = tracks[index]!
const distance = distanceToTrack(candidate)
if (distance < closestDistance) {
closestTrack = candidate
closestDistance = distance
continue
}
if (distance === closestDistance) {
const centerDistance = Math.abs(pointerY - (candidate.top + candidate.height / 2))
const closestCenterDistance = Math.abs(
pointerY - (closestTrack.top + closestTrack.height / 2),
)
if (centerDistance < closestCenterDistance) closestTrack = candidate
}
}
return closestTrack
}
export interface BuildTrackLayoutsOptions {
cells: SeriesGridCell[]
grid: NormalizedWaveformGridOptions
@@ -210,6 +233,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
name: '',
color: 'transparent',
lineType: 'linear',
lineStyle: 'solid',
pointType: 'none',
errorBar: { visible: false, width: 1.5, capWidth: 8 },
points: [],

View File

@@ -2,6 +2,7 @@ import type { ScaleLinear } from 'd3'
import type {
ResolvedWaveformErrorBarOptions,
WaveformLineType,
WaveformLineStyle,
WaveformPoint,
WaveformPointType,
} from '../../types'
@@ -17,6 +18,7 @@ export interface DisplaySeries {
unit?: string
color: string
lineType: WaveformLineType
lineStyle: WaveformLineStyle
pointType: WaveformPointType
errorBar: ResolvedWaveformErrorBarOptions
points: WaveformPoint[]

View File

@@ -5,6 +5,7 @@ import type {
ResolvedWaveformErrorBarOptions,
WaveformData,
WaveformLineType,
WaveformLineStyle,
WaveformPoint,
WaveformPointType,
} from '../../types'
@@ -17,6 +18,7 @@ export interface PreparedWaveformSeries {
unit?: string
color?: string
lineType: WaveformLineType
lineStyle: WaveformLineStyle
pointType: WaveformPointType
errorBar: ResolvedWaveformErrorBarOptions
points: WaveformPoint[]

View File

@@ -22,6 +22,7 @@ export type {
WaveformZeroLineOptions,
SingleWaveformData,
WaveformLineType,
WaveformLineStyle,
WaveformPointType,
WaveformErrorBarOptions,
ResolvedWaveformErrorBarOptions,

View File

@@ -21,6 +21,7 @@ export type {
WaveformPoint,
WaveformSeries,
WaveformLineType,
WaveformLineStyle,
WaveformPointType,
WaveformErrorBarOptions,
WaveformGridOptions,

View File

@@ -6,6 +6,7 @@ import type { DisplaySeries } from '../core/types'
import {
waveformLegendErrorBarPath,
waveformLegendLinePath,
waveformLineDasharray,
waveformPointSymbolPath,
} from './seriesStyle'
@@ -82,6 +83,7 @@ function toggleSeries(seriesId: string) {
viewBox="0 0 26 16"
aria-hidden="true"
:data-line-type="item.lineType"
:data-line-style="item.lineStyle"
:data-point-type="item.pointType"
:data-error-bar-visible="item.errorBar.visible || undefined"
>
@@ -90,6 +92,7 @@ function toggleSeries(seriesId: string) {
class="waveform-legend__line"
:d="waveformLegendLinePath(item.lineType) ?? undefined"
:stroke="item.color"
:stroke-dasharray="waveformLineDasharray(item.lineStyle)"
stroke-width="1.5"
fill="none"
/>

View File

@@ -3,7 +3,7 @@ import { computed } from 'vue'
import { resolveWaveformPointErrors } from '../../core'
import type { TrackLayout, TrackSeriesPath } from '../core/types'
import { waveformPointSeriesPath } from './seriesStyle'
import { waveformLineDasharray, waveformPointSeriesPath } from './seriesStyle'
const props = defineProps<{
track: TrackLayout
@@ -63,8 +63,10 @@ const renderedSeriesPaths = computed<RenderedSeriesPath[]>(() =>
:data-series-name="seriesPath.series.name || undefined"
:data-y-axis-index="seriesPath.yAxisIndex"
:data-line-type="seriesPath.series.lineType"
:data-line-style="seriesPath.series.lineStyle"
:d="seriesPath.path"
:stroke="seriesPath.series.color"
:stroke-dasharray="waveformLineDasharray(seriesPath.series.lineStyle)"
/>
<g

View File

@@ -216,6 +216,7 @@ watch(
v-for="tick in track.xMinorTicks"
:key="`x-minor-${track.index}-${tick}`"
data-grid-direction="vertical"
:stroke="track.gridLines.verticalColor"
:x1="track.xScale(tick)"
:x2="track.xScale(tick)"
y1="0"
@@ -227,6 +228,7 @@ watch(
v-for="tick in track.yMinorTicks"
:key="`y-minor-${track.index}-${tick}`"
data-grid-direction="horizontal"
:stroke="track.gridLines.horizontalColor"
x1="0"
:x2="track.width ?? innerWidth"
:y1="track.yScale(tick)"
@@ -242,6 +244,7 @@ watch(
v-for="tick in track.xMajorTicks"
:key="`x-major-${track.index}-${tick}`"
data-grid-direction="vertical"
:stroke="track.gridLines.verticalColor"
:x1="track.xScale(tick)"
:x2="track.xScale(tick)"
y1="0"
@@ -253,6 +256,7 @@ watch(
v-for="tick in track.yMajorTicks"
:key="`y-major-${track.index}-${tick}`"
data-grid-direction="horizontal"
:stroke="track.gridLines.horizontalColor"
x1="0"
:x2="track.width ?? innerWidth"
:y1="track.yScale(tick)"

View File

@@ -7,7 +7,7 @@ import {
type SymbolType,
} from 'd3'
import type { WaveformLineType, WaveformPointType } from '../../types'
import type { WaveformLineStyle, WaveformLineType, WaveformPointType } from '../../types'
const LEGEND_SWATCH_CENTER_X = 13
const LEGEND_ERROR_BAR_TOP = 2
@@ -82,6 +82,12 @@ export function waveformLegendLinePath(lineType: WaveformLineType): string | nul
return 'M1 8H25'
}
export function waveformLineDasharray(lineStyle: WaveformLineStyle): string | undefined {
if (lineStyle === 'dashed') return '8 5'
if (lineStyle === 'dash-dot') return '8 5 1.5 5'
return undefined
}
export function waveformLegendErrorBarPath(capWidth: number): string {
const resolvedCapWidth =
Number.isFinite(capWidth) && capWidth > 0

View File

@@ -17,6 +17,7 @@ export type {
WaveformFrameStyle,
SingleWaveformData,
WaveformSeries,
WaveformLineStyle,
WaveformData,
NormalizedWaveformSeries,
} from '../types'