fix(chart): refine axis number formatting
All checks were successful
Package component / package (push) Successful in 4m2s

This commit is contained in:
李启源
2026-08-04 11:28:42 +08:00
parent f98c264562
commit c5aa3c662a
22 changed files with 217 additions and 262 deletions

View File

@@ -3,69 +3,78 @@ import { describe, expect, it } from 'vitest'
import {
formatAnnotationTime,
formatAxisTime,
formatAxisTimeExponent,
formatEndpointTime,
formatPlainNumber,
formatScientificAxisExponent,
formatScientificAxisLabel,
formatTooltipNumber,
formatTooltipTime,
resolveScientificAxisExponent,
shouldUseScientificAxisLabel,
} from './formatters'
describe('waveform number formatters', () => {
it('uses the reference Y-axis scientific notation boundaries', () => {
expect(shouldUseScientificAxisLabel(0)).toBe(false)
expect(shouldUseScientificAxisLabel(0.009)).toBe(true)
expect(shouldUseScientificAxisLabel(0.01)).toBe(false)
expect(shouldUseScientificAxisLabel(99.99)).toBe(false)
expect(shouldUseScientificAxisLabel(100)).toBe(true)
expect(shouldUseScientificAxisLabel(0.000999)).toBe(true)
expect(shouldUseScientificAxisLabel(0.001)).toBe(false)
expect(shouldUseScientificAxisLabel(999.999)).toBe(false)
expect(shouldUseScientificAxisLabel(1000)).toBe(true)
})
it('shares one separate exponent across an axis', () => {
const positiveAxis = { axisMin: 0, axisMax: 254 }
expect(formatScientificAxisLabel(127, positiveAxis)).toBe('1.27')
expect(formatScientificAxisLabel(254, positiveAxis)).toBe('2.54')
expect(formatScientificAxisExponent(0, 254)).toBe('E+02')
it('prefixes one shared exponent to the largest visible tick', () => {
const positiveAxis = { axisMin: 1000, axisMax: 3000 }
expect(formatScientificAxisLabel(1000, positiveAxis)).toBe('1')
expect(formatScientificAxisLabel(2000, positiveAxis)).toBe('2')
expect(formatScientificAxisLabel(3000, positiveAxis)).toBe('E+03 3')
expect(formatScientificAxisLabel(2000, { ...positiveAxis, topTickValue: 2000 })).toBe('E+03 2')
expect(formatScientificAxisExponent(1000, 3000)).toBe('E+03')
expect(formatScientificAxisExponent(0, 1e120)).toBe('E+120')
const tinyAxis = { axisMin: 0, axisMax: 0.0002 }
expect(formatScientificAxisLabel(0.0001, tinyAxis)).toBe('1.00')
expect(formatScientificAxisLabel(0.0002, tinyAxis)).toBe('2.00')
expect(formatScientificAxisExponent(0, 0.0002)).toBe('E-04')
const negativeAxis = { axisMin: -254, axisMax: 0 }
expect(formatScientificAxisLabel(-254, negativeAxis)).toBe('-2.54')
expect(formatScientificAxisLabel(0, negativeAxis)).toBe('0.00')
expect(formatScientificAxisExponent(-254, 0)).toBe('E+02')
const tinyAxis = { axisMin: 0.0001, axisMax: 0.0003 }
expect(formatScientificAxisLabel(0.0001, tinyAxis)).toBe('1')
expect(formatScientificAxisLabel(0.0002, tinyAxis)).toBe('2')
expect(formatScientificAxisLabel(0.0003, tinyAxis)).toBe('E-04 3')
expect(formatScientificAxisExponent(0.0001, 0.0003)).toBe('E-04')
})
it('keeps plain axes at two decimals and removes negative zero', () => {
expect(formatScientificAxisLabel(99.99, { axisMin: 0, axisMax: 99.99 })).toBe('99.99')
expect(formatScientificAxisLabel(0.01, { axisMin: 0, axisMax: 0.01 })).toBe('0.01')
expect(formatScientificAxisLabel(-0.001, { axisMin: -1, axisMax: 1 })).toBe('0.00')
it('derives the exponent from Math.max(axisMin, axisMax)', () => {
expect(resolveScientificAxisExponent(-9000, -1000)).toBe(3)
expect(resolveScientificAxisExponent(-10_000, 3000)).toBe(3)
expect(resolveScientificAxisExponent(-1000, 0)).toBeNull()
expect(resolveScientificAxisExponent(0, 0)).toBeNull()
})
it('keeps five significant digits, removes trailing zeroes, and limits decimals to four', () => {
expect(formatScientificAxisLabel(123.456, { axisMin: 0, axisMax: 999 })).toBe('123.46')
expect(formatScientificAxisLabel(1234.56, { axisMin: 0, axisMax: 9999 })).toBe('1.2346')
expect(formatScientificAxisLabel(3000, { axisMin: 1000, axisMax: 3000 })).toBe('E+03 3')
expect(formatScientificAxisLabel(-0, { axisMin: -1, axisMax: 1 })).toBe('0')
expect(formatScientificAxisLabel(Number.NaN)).toBe('NaN')
expect(formatScientificAxisLabel(Number.POSITIVE_INFINITY)).toBe('Infinity')
expect(formatScientificAxisExponent(0, 0)).toBeNull()
})
it('formats X-axis ticks and endpoints from the selected display unit', () => {
it('formats X-axis ticks and endpoints as plain integers in the selected display unit', () => {
const domain: [number, number] = [0, 1]
expect(formatAxisTime(0.5, 'ms', domain)).toBe('0.50')
expect(formatEndpointTime(1, domain, 'ms')).toBe('1.00')
expect(formatAxisTimeExponent(domain, 'ms')).toBe('E+03')
expect(formatAxisTime(0.5, 's', domain)).toBe('0.50')
expect(formatEndpointTime(1, domain, 's')).toBe('1.00')
expect(formatAxisTimeExponent(domain, 's')).toBeNull()
expect(formatAxisTime(0.5004, 'ms', domain)).toBe('500')
expect(formatEndpointTime(1, domain, 'ms')).toBe('1000')
expect(formatAxisTime(0.5, 's', domain)).toBe('1')
expect(formatEndpointTime(1, domain, 's')).toBe('1')
const tinyDomain: [number, number] = [0, 0.000001]
expect(formatEndpointTime(0.000001, tinyDomain, 's')).toBe('1.00')
expect(formatAxisTimeExponent(tinyDomain, 's')).toBe('E-06')
expect(formatEndpointTime(0.000001, tinyDomain, 's')).toBe('0')
expect(formatAxisTime(-0.000001, 's', tinyDomain)).toBe('0')
expect(formatAxisTime(1e21, 's')).toBe('1000000000000000000000')
expect(formatAxisTime(Number.POSITIVE_INFINITY, 's')).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(formatTooltipTime(1.234567, 's')).toBe('1.2346')
expect(formatTooltipTime(1, 'ms')).toBe('1,000')
expect(formatPlainNumber(0.0000001)).toBe('0.0000001')
expect(formatPlainNumber(1e21)).toBe('1000000000000000000000')
expect(formatPlainNumber(-0)).toBe('0')

View File

@@ -4,22 +4,28 @@
export type TimeUnit = 'ms' | 's'
export interface ScientificAxisLabelOptions {
/** @deprecated Y-axis labels always use five significant digits before localization. */
precision?: number
axisMin?: number
axisMax?: number
}
/** @deprecated Use ScientificAxisLabelOptions. */
export type ScientificYAxisLabelOptions = ScientificAxisLabelOptions & {
topTickValue?: number
}
const DEFAULT_Y_AXIS_PRECISION = 2
const SCIENTIFIC_MIN_ABSOLUTE_VALUE = 0.01
const SCIENTIFIC_MAX_PLAIN_ABSOLUTE_VALUE = 100
/** @deprecated Use ScientificAxisLabelOptions. */
export type ScientificYAxisLabelOptions = ScientificAxisLabelOptions
const SCIENTIFIC_MIN_ABSOLUTE_VALUE = 0.001
const SCIENTIFIC_MAX_PLAIN_ABSOLUTE_VALUE = 1000
const Y_AXIS_NUMBER_FORMATTER = new Intl.NumberFormat('zh-CN', {
maximumFractionDigits: 4,
})
const TOOLTIP_NUMBER_FORMATTER = new Intl.NumberFormat('zh-CN', {
maximumFractionDigits: 4,
})
const X_AXIS_TIME_FORMATTER = new Intl.NumberFormat('zh-CN', {
maximumFractionDigits: 0,
useGrouping: false,
})
function formatFixedNumber(value: number, precision: number): string {
const formatted = value.toFixed(Math.max(0, precision))
@@ -38,9 +44,10 @@ export function shouldUseScientificAxisLabel(maxAbsoluteValue: number): boolean
export function resolveScientificAxisExponent(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 shouldUseScientificAxisLabel(maxAbsoluteValue)
? Math.floor(Math.log10(maxAbsoluteValue))
const maxValue = Math.max(axisMin, axisMax)
const absoluteMaxValue = Math.abs(maxValue)
return shouldUseScientificAxisLabel(absoluteMaxValue)
? Math.floor(Math.log10(absoluteMaxValue))
: null
}
@@ -49,6 +56,13 @@ function formatExponent(exponent: number): string {
return `E${sign}${Math.abs(exponent).toString().padStart(2, '0')}`
}
function formatYAxisNumber(value: number): string {
if (Object.is(value, -0)) return '0'
const roundedValue = value === 0 ? 0 : Number(value.toPrecision(5))
const formatted = Y_AXIS_NUMBER_FORMATTER.format(roundedValue)
return formatted === '-0' ? '0' : formatted
}
/** Format a Y-axis tick, sharing one exponent derived from the complete axis domain. */
export function formatScientificAxisLabel(
value: number,
@@ -56,10 +70,13 @@ export function formatScientificAxisLabel(
): string {
if (!Number.isFinite(value)) return String(value)
const precision = options.precision ?? DEFAULT_Y_AXIS_PRECISION
const exponent = resolveScientificAxisExponent(options.axisMin, options.axisMax)
const scaledValue = exponent === null ? value : value / 10 ** exponent
return formatFixedNumber(scaledValue, precision)
const formattedValue = formatYAxisNumber(scaledValue)
const topTickValue = options.topTickValue ?? options.axisMax
return exponent !== null && value === topTickValue
? `${formatExponent(exponent)} ${formattedValue}`
: formattedValue
}
/** Return the shared E-style multiplier for an axis, or null for a plain axis. */
@@ -86,6 +103,13 @@ export function formatTooltipNumber(value: number): string {
return TOOLTIP_NUMBER_FORMATTER.format(value)
}
/** Format an X-axis time value as a plain integer without grouping separators. */
function formatAxisTimeValue(value: number): string {
if (!Number.isFinite(value)) return String(value)
const formatted = X_AXIS_TIME_FORMATTER.format(value)
return formatted === '-0' ? '0' : formatted
}
/** 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)
@@ -134,54 +158,34 @@ export function endpointFractionDigits(domain: [number, number], timeUnit: TimeU
}
/**
* 按完整 X 轴显示域格式化端点时间
* X 轴端点时间格式化为当前显示单位下的普通整数
* @param value 时间值(秒)
* @param domain 数据域
* @param _domain 数据域(为保持现有调用签名而保留)
* @param timeUnit 时间单位
* @returns 格式化的时间字符串
*/
export function formatEndpointTime(
value: number,
domain: [number, number],
_domain: [number, number],
timeUnit: TimeUnit,
): string {
const [axisMin, axisMax] = domain.map((domainValue) => displayTime(domainValue, timeUnit)) as [
number,
number,
]
return formatScientificAxisLabel(displayTime(value, timeUnit), {
axisMin,
axisMax,
})
return formatAxisTimeValue(displayTime(value, timeUnit))
}
/**
* 按完整 X 轴显示域格式化时间刻度
* X 轴时间刻度格式化为当前显示单位下的普通整数
* @param value 时间值(秒)
* @param timeUnit 时间单位
* @param _domain 数据域(为保持现有调用签名而保留)
* @returns 格式化的时间字符串
*/
export function formatAxisTime(
value: number,
timeUnit: TimeUnit,
domain?: [number, number],
_domain?: [number, number],
): string {
const displayValue = displayTime(value, timeUnit)
const displayDomain = domain?.map((domainValue) => displayTime(domainValue, timeUnit)) as
[number, number] | undefined
return formatScientificAxisLabel(displayValue, {
axisMin: displayDomain?.[0],
axisMax: displayDomain?.[1],
})
}
/** Return the shared multiplier for an X-axis domain in its selected display unit. */
export function formatAxisTimeExponent(
domain: [number, number],
timeUnit: TimeUnit,
): string | null {
const [axisMin, axisMax] = domain.map((value) => displayTime(value, timeUnit)) as [number, number]
return formatScientificAxisExponent(axisMin, axisMax)
void _domain
return formatAxisTimeValue(displayTime(value, timeUnit))
}
/**
@@ -191,11 +195,7 @@ export function formatAxisTimeExponent(
* @returns 格式化的时间字符串
*/
export function formatTooltipTime(value: number, timeUnit: TimeUnit): string {
const displayValue = displayTime(value, timeUnit)
return displayValue.toLocaleString('zh-CN', {
minimumFractionDigits: 4,
maximumFractionDigits: 4,
})
return formatTooltipNumber(displayTime(value, timeUnit))
}
/** Format an annotation X coordinate in the selected display time unit. */

View File

@@ -11,7 +11,6 @@ export {
endpointFractionDigits,
formatEndpointTime,
formatAxisTime,
formatAxisTimeExponent,
formatTooltipTime,
formatAnnotationTime,
formatPlainNumber,