2026-07-27 15:05:13 +08:00
|
|
|
import { scaleLinear } from 'd3'
|
2026-07-20 10:21:30 +08:00
|
|
|
|
2026-07-27 15:05:13 +08:00
|
|
|
import type { WaveformOverlayMode } from '../../types'
|
|
|
|
|
import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../../utils'
|
|
|
|
|
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
|
2026-07-22 16:36:48 +08:00
|
|
|
import { MAX_MULTI_Y_AXIS_COUNT, Y_AXIS_EXPONENT_GAP } from './constants'
|
|
|
|
|
|
|
|
|
|
// 导出常量供外部使用
|
|
|
|
|
export { MAX_MULTI_Y_AXIS_COUNT, Y_AXIS_EXPONENT_GAP } from './constants'
|
2026-07-20 15:35:19 +08:00
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
interface YAxisSeriesGroup {
|
|
|
|
|
index: number
|
|
|
|
|
side: 'left' | 'right'
|
|
|
|
|
seriesList: DisplaySeries[]
|
|
|
|
|
domain: [number, number]
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resolveAxisSides(axisCount: number): Array<'left' | 'right'> {
|
|
|
|
|
if (axisCount >= 4) return ['left', 'left', 'right', 'right']
|
|
|
|
|
if (axisCount === 3) return ['left', 'right', 'right']
|
|
|
|
|
if (axisCount === 2) return ['left', 'right']
|
|
|
|
|
return ['left']
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-22 16:36:48 +08:00
|
|
|
// 使用 WeakMap 进行缓存优化,避免手动清理
|
|
|
|
|
const yAxisGroupsCache = new WeakMap<DisplayTrack, Map<WaveformOverlayMode, YAxisSeriesGroup[]>>()
|
2026-07-20 15:35:19 +08:00
|
|
|
|
|
|
|
|
export function buildYAxisSeriesGroups(
|
|
|
|
|
track: DisplayTrack,
|
|
|
|
|
overlayMode: WaveformOverlayMode,
|
|
|
|
|
): YAxisSeriesGroup[] {
|
2026-07-22 16:36:48 +08:00
|
|
|
let trackCache = yAxisGroupsCache.get(track)
|
2026-07-20 15:35:19 +08:00
|
|
|
if (!trackCache) {
|
|
|
|
|
trackCache = new Map()
|
2026-07-22 16:36:48 +08:00
|
|
|
yAxisGroupsCache.set(track, trackCache)
|
2026-07-20 15:35:19 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const cached = trackCache.get(overlayMode)
|
|
|
|
|
if (cached) return cached
|
|
|
|
|
const axisCount =
|
|
|
|
|
overlayMode === 'multi-axis'
|
2026-07-20 17:57:52 +08:00
|
|
|
? Math.min(track.visibleSeries.length, MAX_MULTI_Y_AXIS_COUNT)
|
|
|
|
|
: Math.min(track.visibleSeries.length, 1)
|
2026-07-20 15:35:19 +08:00
|
|
|
const sides = resolveAxisSides(axisCount)
|
|
|
|
|
const grouped = Array.from({ length: axisCount }, (_, index) => ({
|
|
|
|
|
index,
|
|
|
|
|
side: sides[index],
|
|
|
|
|
seriesList: [] as DisplaySeries[],
|
|
|
|
|
domain: [0, 1] as [number, number],
|
|
|
|
|
}))
|
|
|
|
|
|
2026-07-20 17:57:52 +08:00
|
|
|
track.visibleSeries.forEach((series, index) => {
|
2026-07-20 15:35:19 +08:00
|
|
|
grouped[Math.min(index, axisCount - 1)]?.seriesList.push(series)
|
|
|
|
|
})
|
|
|
|
|
grouped.forEach((group) => {
|
|
|
|
|
if (overlayMode === 'single-axis') {
|
|
|
|
|
group.domain = track.yDomain
|
|
|
|
|
} else {
|
|
|
|
|
const yDomainValues = group.seriesList.flatMap((series) => series.yDomain)
|
|
|
|
|
group.domain = yDomainValues.length > 0 ? paddedDomain(yDomainValues) : track.yDomain
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
// 缓存结果
|
|
|
|
|
trackCache.set(overlayMode, grouped)
|
|
|
|
|
return grouped
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 15:05:13 +08:00
|
|
|
export function axisTextMetrics(domain: [number, number]): {
|
2026-07-20 15:35:19 +08:00
|
|
|
exponentLabel: string | null
|
|
|
|
|
exponentWidth: number
|
|
|
|
|
tickTextWidth: number
|
|
|
|
|
} {
|
|
|
|
|
const scale = scaleLinear(domain, [1, 0]).nice()
|
|
|
|
|
const [axisMin, axisMax] = scale.domain()
|
|
|
|
|
const values = scale.ticks(10)
|
|
|
|
|
const maximumTickCharacters = Math.max(
|
|
|
|
|
1,
|
|
|
|
|
...values.map((value) => formatScientificAxisLabel(value, { axisMin, axisMax }).length),
|
|
|
|
|
)
|
|
|
|
|
const exponentLabel = formatScientificAxisExponent(axisMin, axisMax)
|
|
|
|
|
return {
|
|
|
|
|
exponentLabel,
|
|
|
|
|
exponentWidth: exponentLabel ? exponentLabel.length * Y_AXIS_CHARACTER_WIDTH : 0,
|
|
|
|
|
tickTextWidth: maximumTickCharacters * Y_AXIS_CHARACTER_WIDTH,
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function axisExponentClearance(domain: [number, number]): number {
|
|
|
|
|
const { exponentLabel, exponentWidth } = axisTextMetrics(domain)
|
|
|
|
|
return exponentLabel ? exponentWidth + Y_AXIS_EXPONENT_GAP : 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
|
|
|
|
|
return (
|
|
|
|
|
axisTextMetrics(group.domain).tickTextWidth +
|
|
|
|
|
axisExponentClearance(group.domain) +
|
|
|
|
|
Y_AXIS_TICK_PADDING +
|
|
|
|
|
Y_AXIS_LABEL_GAP +
|
|
|
|
|
Y_AXIS_LABEL_BAND_WIDTH +
|
|
|
|
|
Y_AXIS_OUTER_PADDING
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number {
|
|
|
|
|
return (
|
|
|
|
|
axisTextMetrics(group.domain).tickTextWidth +
|
|
|
|
|
axisExponentClearance(group.domain) +
|
|
|
|
|
Y_AXIS_TICK_PADDING +
|
|
|
|
|
Y_AXIS_OUTER_PADDING
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export function measureTrackYAxisClearance(
|
|
|
|
|
track: DisplayTrack,
|
|
|
|
|
overlayMode: WaveformOverlayMode,
|
|
|
|
|
): { left: number; right: number } {
|
|
|
|
|
return buildYAxisSeriesGroups(track, overlayMode).reduce(
|
|
|
|
|
(clearance, group) => {
|
|
|
|
|
clearance[group.side] +=
|
2026-07-20 17:57:52 +08:00
|
|
|
overlayMode === 'multi-axis' || track.visibleSeries.length === 1
|
2026-07-20 15:35:19 +08:00
|
|
|
? measureYAxisGroupClearance(group)
|
|
|
|
|
: measureYAxisGroupTickClearance(group)
|
|
|
|
|
return clearance
|
|
|
|
|
},
|
|
|
|
|
{ left: 0, right: 0 },
|
|
|
|
|
)
|
|
|
|
|
}
|
2026-07-20 10:21:30 +08:00
|
|
|
|
2026-07-22 16:36:48 +08:00
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-27 15:05:13 +08:00
|
|
|
export { buildTrackLayouts, type BuildTrackLayoutsOptions } from './trackLayoutBuilder'
|