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

@@ -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[]