feat(chart): add multi-axis overlay controls

This commit is contained in:
李启源
2026-07-20 15:35:19 +08:00
parent 0cab86f7b6
commit c9e4dae25f
44 changed files with 1882 additions and 523 deletions

View File

@@ -19,8 +19,8 @@ describe('buildMinorTicks', () => {
const ticks = buildMinorTicks([-7000, -6000, -5000], 5, [-7950, -4100])
expect(ticks).toEqual([
-7800, -7600, -7400, -7200, -6800, -6600, -6400, -6200, -5800, -5600, -5400,
-5200, -4800, -4600, -4400, -4200,
-7800, -7600, -7400, -7200, -6800, -6600, -6400, -6200, -5800, -5600, -5400, -5200, -4800,
-4600, -4400, -4200,
])
expect(ticks).not.toContain(-7950)
expect(ticks).not.toContain(-4100)

View File

@@ -39,7 +39,10 @@ export function buildMinorTicks(
const nextValue = intervalBoundaries[index + 1]
if (nextValue === undefined) return []
const step = (nextValue - value) / subdivisions
return Array.from({ length: subdivisions - 1 }, (_, minorIndex) => value + step * (minorIndex + 1))
return Array.from(
{ length: subdivisions - 1 },
(_, minorIndex) => value + step * (minorIndex + 1),
)
})
if (!domain) return minorTicks

View File

@@ -2,41 +2,64 @@ import { describe, expect, it } from 'vitest'
import {
formatAnnotationTime,
formatAxisTime,
formatAxisTimeExponent,
formatEndpointTime,
formatPlainNumber,
formatScientificYAxisLabel,
formatScientificAxisExponent,
formatScientificAxisLabel,
formatTooltipNumber,
shouldUseScientificYAxisLabel,
shouldUseScientificAxisLabel,
} 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)
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)
})
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')
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')
expect(formatScientificAxisExponent(0, 1e120)).toBe('E+120')
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 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, topTickValue: 0 }
expect(formatScientificYAxisLabel(-254, negativeAxis)).toBe('-2.54')
expect(formatScientificYAxisLabel(0, negativeAxis)).toBe('E+02 0.00')
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')
})
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')
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')
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', () => {
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()
const tinyDomain: [number, number] = [0, 0.000001]
expect(formatEndpointTime(0.000001, tinyDomain, 's')).toBe('1.00')
expect(formatAxisTimeExponent(tinyDomain, 's')).toBe('E-06')
})
it('formats tooltip and raw values for their display contexts', () => {

View File

@@ -3,10 +3,14 @@
*/
export type TimeUnit = 'ms' | 's'
export interface ScientificYAxisLabelOptions {
export interface ScientificAxisLabelOptions {
precision?: number
axisMin?: number
axisMax?: number
}
/** @deprecated Use ScientificAxisLabelOptions. */
export type ScientificYAxisLabelOptions = ScientificAxisLabelOptions & {
topTickValue?: number
}
@@ -23,7 +27,7 @@ function formatFixedNumber(value: number, precision: number): string {
}
/** Whether an axis magnitude should use one shared scientific exponent. */
export function shouldUseScientificYAxisLabel(maxAbsoluteValue: number): boolean {
export function shouldUseScientificAxisLabel(maxAbsoluteValue: number): boolean {
return (
Number.isFinite(maxAbsoluteValue) &&
(maxAbsoluteValue >= SCIENTIFIC_MAX_PLAIN_ABSOLUTE_VALUE ||
@@ -31,11 +35,11 @@ export function shouldUseScientificYAxisLabel(maxAbsoluteValue: number): boolean
)
}
function resolveScientificExponent(axisMin?: number, axisMax?: number): number | null {
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 shouldUseScientificYAxisLabel(maxAbsoluteValue)
return shouldUseScientificAxisLabel(maxAbsoluteValue)
? Math.floor(Math.log10(maxAbsoluteValue))
: null
}
@@ -46,20 +50,33 @@ function formatExponent(exponent: number): string {
}
/** Format a Y-axis tick, sharing one exponent derived from the complete axis domain. */
export function formatScientificYAxisLabel(
export function formatScientificAxisLabel(
value: number,
options: ScientificYAxisLabelOptions = {},
options: ScientificAxisLabelOptions = {},
): string {
if (!Number.isFinite(value)) return String(value)
const precision = options.precision ?? DEFAULT_Y_AXIS_PRECISION
const exponent = resolveScientificExponent(options.axisMin, options.axisMax)
const exponent = resolveScientificAxisExponent(options.axisMin, options.axisMax)
const scaledValue = exponent === null ? value : value / 10 ** exponent
const formattedValue = formatFixedNumber(scaledValue, precision)
return formatFixedNumber(scaledValue, precision)
}
return exponent !== null && value === options.topTickValue
? `${formatExponent(exponent)} ${formattedValue}`
: formattedValue
/** Return the shared E-style multiplier for an axis, or null for a plain axis. */
export function formatScientificAxisExponent(axisMin?: number, axisMax?: number): string | null {
const exponent = resolveScientificAxisExponent(axisMin, axisMax)
return exponent === null ? null : formatExponent(exponent)
}
/** @deprecated Use shouldUseScientificAxisLabel. */
export const shouldUseScientificYAxisLabel = shouldUseScientificAxisLabel
/** @deprecated Use formatScientificAxisLabel. */
export function formatScientificYAxisLabel(
value: number,
options: ScientificYAxisLabelOptions = {},
): string {
return formatScientificAxisLabel(value, options)
}
/** Format tooltip values as localized plain numbers with at most four decimal places. */
@@ -117,7 +134,7 @@ export function endpointFractionDigits(domain: [number, number], timeUnit: TimeU
}
/**
* 格式化端点时间(动态精度,本地化格式)
* 按完整 X 轴显示域格式化端点时间
* @param value 时间值(秒)
* @param domain 数据域
* @param timeUnit 时间单位
@@ -128,36 +145,45 @@ export function formatEndpointTime(
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,
const [axisMin, axisMax] = domain.map((domainValue) => displayTime(domainValue, timeUnit)) as [
number,
number,
]
return formatScientificAxisLabel(displayTime(value, timeUnit), {
axisMin,
axisMax,
})
}
/**
* 格式化坐标轴时间(整数,本地化格式)
* 按完整 X 轴显示域格式化时间刻度
* @param value 时间值(秒)
* @param timeUnit 时间单位
* @returns 格式化的时间字符串
*/
export function formatAxisTime(value: number, timeUnit: TimeUnit): string {
export function formatAxisTime(
value: number,
timeUnit: TimeUnit,
domain?: [number, number],
): string {
const displayValue = displayTime(value, timeUnit)
return displayValue.toLocaleString('zh-CN', {
maximumFractionDigits: 0,
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)
}
/**
* 格式化悬浮提示时间4 位小数,本地化格式)
* @param value 时间值(秒)

View File

@@ -11,12 +11,18 @@ export {
endpointFractionDigits,
formatEndpointTime,
formatAxisTime,
formatAxisTimeExponent,
formatTooltipTime,
formatAnnotationTime,
formatPlainNumber,
formatScientificAxisLabel,
formatScientificAxisExponent,
formatScientificYAxisLabel,
formatTooltipNumber,
resolveScientificAxisExponent,
shouldUseScientificAxisLabel,
shouldUseScientificYAxisLabel,
type ScientificAxisLabelOptions,
type ScientificYAxisLabelOptions,
type TimeUnit,
} from './formatters'