29
src/utils/domain.ts
Normal file
29
src/utils/domain.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { extent } from 'd3'
|
||||
|
||||
/**
|
||||
* 计算带边距的数据域
|
||||
* @param values 数值数组
|
||||
* @returns 数据域 [最小值, 最大值],如果数组为空返回 [0, 1]
|
||||
*/
|
||||
export function paddedDomain(values: number[]): [number, number] {
|
||||
if (values.length === 0) return [0, 1]
|
||||
const [minimum = 0, maximum = 1] = extent(values)
|
||||
if (minimum !== maximum) return [minimum, maximum]
|
||||
const padding = Math.abs(minimum) * 0.05 || 0.5
|
||||
return [minimum - padding, maximum + padding]
|
||||
}
|
||||
|
||||
/**
|
||||
* 在主刻度之间生成次要刻度
|
||||
* @param values 主刻度值数组
|
||||
* @param subdivisions 细分数量,默认 5
|
||||
* @returns 次要刻度值数组
|
||||
*/
|
||||
export function buildMinorTicks(values: number[], subdivisions = 5): number[] {
|
||||
return values.flatMap((value, index) => {
|
||||
const nextValue = values[index + 1]
|
||||
if (nextValue === undefined) return []
|
||||
const step = (nextValue - value) / subdivisions
|
||||
return Array.from({ length: subdivisions - 1 }, (_, minorIndex) => value + step * (minorIndex + 1))
|
||||
})
|
||||
}
|
||||
57
src/utils/formatters.test.ts
Normal file
57
src/utils/formatters.test.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
formatAnnotationTime,
|
||||
formatPlainNumber,
|
||||
formatScientificYAxisLabel,
|
||||
formatTooltipNumber,
|
||||
shouldUseScientificYAxisLabel,
|
||||
} from './formatters'
|
||||
|
||||
describe('waveform number formatters', () => {
|
||||
it('uses the reference Y-axis scientific notation boundaries', () => {
|
||||
expect(shouldUseScientificYAxisLabel(0)).toBe(false)
|
||||
expect(shouldUseScientificYAxisLabel(0.009)).toBe(true)
|
||||
expect(shouldUseScientificYAxisLabel(0.01)).toBe(false)
|
||||
expect(shouldUseScientificYAxisLabel(99.99)).toBe(false)
|
||||
expect(shouldUseScientificYAxisLabel(100)).toBe(true)
|
||||
})
|
||||
|
||||
it('shares one exponent and prefixes only the top visible tick', () => {
|
||||
const positiveAxis = { axisMin: 0, axisMax: 254, topTickValue: 254 }
|
||||
expect(formatScientificYAxisLabel(127, positiveAxis)).toBe('1.27')
|
||||
expect(formatScientificYAxisLabel(254, positiveAxis)).toBe('E+02 2.54')
|
||||
|
||||
const tinyAxis = { axisMin: 0, axisMax: 0.0002, topTickValue: 0.0002 }
|
||||
expect(formatScientificYAxisLabel(0.0001, tinyAxis)).toBe('1.00')
|
||||
expect(formatScientificYAxisLabel(0.0002, tinyAxis)).toBe('E-04 2.00')
|
||||
|
||||
const negativeAxis = { axisMin: -254, axisMax: 0, topTickValue: 0 }
|
||||
expect(formatScientificYAxisLabel(-254, negativeAxis)).toBe('-2.54')
|
||||
expect(formatScientificYAxisLabel(0, negativeAxis)).toBe('E+02 0.00')
|
||||
})
|
||||
|
||||
it('keeps plain axes at two decimals and removes negative zero', () => {
|
||||
expect(formatScientificYAxisLabel(99.99, { axisMin: 0, axisMax: 99.99 })).toBe('99.99')
|
||||
expect(formatScientificYAxisLabel(0.01, { axisMin: 0, axisMax: 0.01 })).toBe('0.01')
|
||||
expect(formatScientificYAxisLabel(-0.001, { axisMin: -1, axisMax: 1 })).toBe('0.00')
|
||||
expect(formatScientificYAxisLabel(Number.NaN)).toBe('NaN')
|
||||
expect(formatScientificYAxisLabel(Number.POSITIVE_INFINITY)).toBe('Infinity')
|
||||
})
|
||||
|
||||
it('formats tooltip and raw values for their display contexts', () => {
|
||||
expect(formatTooltipNumber(12345.67891)).toBe('12,345.6789')
|
||||
expect(formatTooltipNumber(-0)).toBe('0')
|
||||
expect(formatTooltipNumber(Number.POSITIVE_INFINITY)).toBe('Infinity')
|
||||
expect(formatPlainNumber(0.0000001)).toBe('0.0000001')
|
||||
expect(formatPlainNumber(1e21)).toBe('1000000000000000000000')
|
||||
expect(formatPlainNumber(-0)).toBe('0')
|
||||
})
|
||||
|
||||
it('formats annotation time in the selected unit without changing source seconds', () => {
|
||||
expect(formatAnnotationTime(1, 'ms')).toBe('1000.000')
|
||||
expect(formatAnnotationTime(1, 's')).toBe('1.000')
|
||||
expect(formatAnnotationTime(-0, 'ms')).toBe('0.000')
|
||||
expect(formatAnnotationTime(Number.NaN, 's')).toBe('NaN')
|
||||
})
|
||||
})
|
||||
179
src/utils/formatters.ts
Normal file
179
src/utils/formatters.ts
Normal file
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* 时间单位类型
|
||||
*/
|
||||
export type TimeUnit = 'ms' | 's'
|
||||
|
||||
export interface ScientificYAxisLabelOptions {
|
||||
precision?: number
|
||||
axisMin?: number
|
||||
axisMax?: number
|
||||
topTickValue?: number
|
||||
}
|
||||
|
||||
const DEFAULT_Y_AXIS_PRECISION = 2
|
||||
const SCIENTIFIC_MIN_ABSOLUTE_VALUE = 0.01
|
||||
const SCIENTIFIC_MAX_PLAIN_ABSOLUTE_VALUE = 100
|
||||
const TOOLTIP_NUMBER_FORMATTER = new Intl.NumberFormat('zh-CN', {
|
||||
maximumFractionDigits: 4,
|
||||
})
|
||||
|
||||
function formatFixedNumber(value: number, precision: number): string {
|
||||
const formatted = value.toFixed(Math.max(0, precision))
|
||||
return /^-0(?:\.0+)?$/.test(formatted) ? formatted.slice(1) : formatted
|
||||
}
|
||||
|
||||
/** Whether an axis magnitude should use one shared scientific exponent. */
|
||||
export function shouldUseScientificYAxisLabel(maxAbsoluteValue: number): boolean {
|
||||
return (
|
||||
Number.isFinite(maxAbsoluteValue) &&
|
||||
(maxAbsoluteValue >= SCIENTIFIC_MAX_PLAIN_ABSOLUTE_VALUE ||
|
||||
(maxAbsoluteValue > 0 && maxAbsoluteValue < SCIENTIFIC_MIN_ABSOLUTE_VALUE))
|
||||
)
|
||||
}
|
||||
|
||||
function resolveScientificExponent(axisMin?: number, axisMax?: number): number | null {
|
||||
if (typeof axisMin !== 'number' || typeof axisMax !== 'number') return null
|
||||
|
||||
const maxAbsoluteValue = Math.max(Math.abs(axisMin), Math.abs(axisMax))
|
||||
return shouldUseScientificYAxisLabel(maxAbsoluteValue)
|
||||
? Math.floor(Math.log10(maxAbsoluteValue))
|
||||
: null
|
||||
}
|
||||
|
||||
function formatExponent(exponent: number): string {
|
||||
const sign = exponent >= 0 ? '+' : '-'
|
||||
return `E${sign}${Math.abs(exponent).toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
/** Format a Y-axis tick, sharing one exponent derived from the complete axis domain. */
|
||||
export function formatScientificYAxisLabel(
|
||||
value: number,
|
||||
options: ScientificYAxisLabelOptions = {},
|
||||
): string {
|
||||
if (!Number.isFinite(value)) return String(value)
|
||||
|
||||
const precision = options.precision ?? DEFAULT_Y_AXIS_PRECISION
|
||||
const exponent = resolveScientificExponent(options.axisMin, options.axisMax)
|
||||
const scaledValue = exponent === null ? value : value / 10 ** exponent
|
||||
const formattedValue = formatFixedNumber(scaledValue, precision)
|
||||
|
||||
return exponent !== null && value === options.topTickValue
|
||||
? `${formatExponent(exponent)} ${formattedValue}`
|
||||
: formattedValue
|
||||
}
|
||||
|
||||
/** Format tooltip values as localized plain numbers with at most four decimal places. */
|
||||
export function formatTooltipNumber(value: number): string {
|
||||
if (!Number.isFinite(value)) return String(value)
|
||||
if (Object.is(value, -0)) return '0'
|
||||
return TOOLTIP_NUMBER_FORMATTER.format(value)
|
||||
}
|
||||
|
||||
/** Convert a number to complete plain decimal text without forcing exponential notation. */
|
||||
export function formatPlainNumber(value: number): string {
|
||||
if (!Number.isFinite(value)) return String(value)
|
||||
if (Object.is(value, -0)) return '0'
|
||||
|
||||
const [mantissaPart, exponentPart] = value.toExponential().split('e')
|
||||
const exponent = Number(exponentPart)
|
||||
const isNegative = mantissaPart.startsWith('-')
|
||||
const digits = mantissaPart.replace('-', '').replace('.', '')
|
||||
const decimalIndex = exponent + 1
|
||||
let plainText: string
|
||||
|
||||
if (decimalIndex <= 0) {
|
||||
plainText = `0.${'0'.repeat(Math.abs(decimalIndex))}${digits}`
|
||||
} else if (decimalIndex >= digits.length) {
|
||||
plainText = `${digits}${'0'.repeat(decimalIndex - digits.length)}`
|
||||
} else {
|
||||
plainText = `${digits.slice(0, decimalIndex)}.${digits.slice(decimalIndex)}`
|
||||
}
|
||||
|
||||
return `${isNegative ? '-' : ''}${plainText}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据时间单位转换显示值
|
||||
* @param value 原始时间值(秒)
|
||||
* @param timeUnit 时间单位
|
||||
* @returns 转换后的显示值
|
||||
*/
|
||||
export function displayTime(value: number, timeUnit: TimeUnit): number {
|
||||
return timeUnit === 'ms' ? value * 1000 : value
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算端点标签的小数位数
|
||||
* @param domain 数据域 [最小值, 最大值]
|
||||
* @param timeUnit 时间单位
|
||||
* @returns 小数位数
|
||||
*/
|
||||
export function endpointFractionDigits(domain: [number, number], timeUnit: TimeUnit): number {
|
||||
const displayedSpan = Math.abs(
|
||||
displayTime(domain[1], timeUnit) - displayTime(domain[0], timeUnit),
|
||||
)
|
||||
if (!Number.isFinite(displayedSpan) || displayedSpan <= 0) return 0
|
||||
return Math.min(4, Math.max(0, Math.ceil(-Math.log10(displayedSpan / 100))))
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化端点时间(动态精度,本地化格式)
|
||||
* @param value 时间值(秒)
|
||||
* @param domain 数据域
|
||||
* @param timeUnit 时间单位
|
||||
* @returns 格式化的时间字符串
|
||||
*/
|
||||
export function formatEndpointTime(
|
||||
value: number,
|
||||
domain: [number, number],
|
||||
timeUnit: TimeUnit,
|
||||
): string {
|
||||
const displayValue = displayTime(value, timeUnit)
|
||||
const digits = endpointFractionDigits(domain, timeUnit)
|
||||
|
||||
// 如果是整数值且计算出的小数位数会导致显示小数,则强制为0
|
||||
if (displayValue === Math.floor(displayValue) && digits > 0) {
|
||||
return displayValue.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 0,
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
}
|
||||
|
||||
return displayValue.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: digits,
|
||||
maximumFractionDigits: digits,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化坐标轴时间(整数,本地化格式)
|
||||
* @param value 时间值(秒)
|
||||
* @param timeUnit 时间单位
|
||||
* @returns 格式化的时间字符串
|
||||
*/
|
||||
export function formatAxisTime(value: number, timeUnit: TimeUnit): string {
|
||||
const displayValue = displayTime(value, timeUnit)
|
||||
return displayValue.toLocaleString('zh-CN', {
|
||||
maximumFractionDigits: 0,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化悬浮提示时间(4 位小数,本地化格式)
|
||||
* @param value 时间值(秒)
|
||||
* @param timeUnit 时间单位
|
||||
* @returns 格式化的时间字符串
|
||||
*/
|
||||
export function formatTooltipTime(value: number, timeUnit: TimeUnit): string {
|
||||
const displayValue = displayTime(value, timeUnit)
|
||||
return displayValue.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 4,
|
||||
maximumFractionDigits: 4,
|
||||
})
|
||||
}
|
||||
|
||||
/** Format an annotation X coordinate in the selected display time unit. */
|
||||
export function formatAnnotationTime(value: number, timeUnit: TimeUnit): string {
|
||||
if (!Number.isFinite(value)) return String(value)
|
||||
return formatFixedNumber(displayTime(value, timeUnit), 3)
|
||||
}
|
||||
50
src/utils/geometry.ts
Normal file
50
src/utils/geometry.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type { WaveformDisplayMode } from '@/types'
|
||||
|
||||
/**
|
||||
* 轨道几何信息
|
||||
*/
|
||||
export interface TrackGeometry {
|
||||
/** 轨道间距 */
|
||||
gap: number
|
||||
/** 坐标轴区域高度 */
|
||||
axisBand: number
|
||||
/** 单个轨道高度 */
|
||||
height: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算轨道布局几何信息
|
||||
* @param trackCount 轨道数量
|
||||
* @param displayMode 显示模式
|
||||
* @param innerHeight 可用高度
|
||||
* @returns 轨道几何信息
|
||||
*/
|
||||
export function resolveTrackGeometry(
|
||||
trackCount: number,
|
||||
displayMode: WaveformDisplayMode,
|
||||
innerHeight: number,
|
||||
): TrackGeometry {
|
||||
if (trackCount <= 0) return { gap: 0, axisBand: 0, height: 0 }
|
||||
|
||||
const desiredGap = displayMode === 'compact' ? 0 : displayMode === 'separated' ? 16 : 14
|
||||
const desiredAxisBand = displayMode === 'independent' ? 30 : 0
|
||||
const desiredReserve = (trackCount - 1) * (desiredGap + desiredAxisBand)
|
||||
const maximumReserve = innerHeight * 0.45
|
||||
const reserveScale = desiredReserve > maximumReserve ? maximumReserve / desiredReserve : 1
|
||||
const gap = desiredGap * reserveScale
|
||||
const axisBand = desiredAxisBand * reserveScale
|
||||
const height = Math.max(1, (innerHeight - (trackCount - 1) * (gap + axisBand)) / trackCount)
|
||||
|
||||
return { gap, axisBand, height }
|
||||
}
|
||||
|
||||
/**
|
||||
* 限制数值在指定范围内
|
||||
* @param value 待限制的值
|
||||
* @param min 最小值
|
||||
* @param max 最大值
|
||||
* @returns 限制后的值
|
||||
*/
|
||||
export function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max)
|
||||
}
|
||||
25
src/utils/index.ts
Normal file
25
src/utils/index.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 工具函数模块统一导出
|
||||
*/
|
||||
|
||||
// 域计算工具
|
||||
export { paddedDomain, buildMinorTicks } from './domain'
|
||||
|
||||
// 格式化工具
|
||||
export {
|
||||
displayTime,
|
||||
endpointFractionDigits,
|
||||
formatEndpointTime,
|
||||
formatAxisTime,
|
||||
formatTooltipTime,
|
||||
formatAnnotationTime,
|
||||
formatPlainNumber,
|
||||
formatScientificYAxisLabel,
|
||||
formatTooltipNumber,
|
||||
shouldUseScientificYAxisLabel,
|
||||
type ScientificYAxisLabelOptions,
|
||||
type TimeUnit,
|
||||
} from './formatters'
|
||||
|
||||
// 几何计算工具
|
||||
export { resolveTrackGeometry, clamp, type TrackGeometry } from './geometry'
|
||||
Reference in New Issue
Block a user