import { scaleLinear } from 'd3' import type { WaveformOverlayMode } from '../../types' import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../../utils' import type { DisplaySeries, DisplayTrack, TrackLayout } 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' 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'] } // 使用 WeakMap 进行缓存优化,避免手动清理 const yAxisGroupsCache = new WeakMap>() export function buildYAxisSeriesGroups( track: DisplayTrack, overlayMode: WaveformOverlayMode, ): YAxisSeriesGroup[] { let trackCache = yAxisGroupsCache.get(track) if (!trackCache) { trackCache = new Map() yAxisGroupsCache.set(track, trackCache) } const cached = trackCache.get(overlayMode) if (cached) return cached const axisCount = overlayMode === 'multi-axis' ? Math.min(track.visibleSeries.length, MAX_MULTI_Y_AXIS_COUNT) : Math.min(track.visibleSeries.length, 1) const sides = resolveAxisSides(axisCount) const grouped = Array.from({ length: axisCount }, (_, index) => ({ index, side: sides[index], seriesList: [] as DisplaySeries[], domain: [0, 1] as [number, number], })) track.visibleSeries.forEach((series, index) => { 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 } export function axisTextMetrics(domain: [number, number]): { 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] += overlayMode === 'multi-axis' || track.visibleSeries.length === 1 ? measureYAxisGroupClearance(group) : measureYAxisGroupTickClearance(group) return clearance }, { left: 0, right: 0 }, ) } type PositionedTrack = Pick export function findClosestTrackAtPointer( 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 { buildTrackLayouts, type BuildTrackLayoutsOptions } from './trackLayoutBuilder'