This commit is contained in:
@@ -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 }
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]),
|
||||
)
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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[]
|
||||
|
||||
@@ -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[]
|
||||
|
||||
Reference in New Issue
Block a user