Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fbf10fdb88 | ||
|
|
c5aa3c662a |
90
README.md
90
README.md
@@ -96,41 +96,41 @@ const data = ref<WaveformData>({
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | 类型 | 默认值 | 说明 |
|
||||
| --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | --------------------------------- |
|
||||
| `data` | `WaveformData` | 必填 | 波形数据 |
|
||||
| `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 |
|
||||
| `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 |
|
||||
| `timeUnit` | `'s' \| 'ms'` | `'ms'` | 坐标轴和 tooltip 展示单位 |
|
||||
| `xLabel` / `yLabel` | `string` | `时间(timeUnit)` / `'幅值'` | 坐标轴名称 |
|
||||
| `lineColor` | `string` | `'#0960bd'` | 单波形默认颜色 |
|
||||
| `width` / `height` | `number` | 自适应 | 组件总尺寸,单位为 CSS 像素 |
|
||||
| `zoomable` / `showTooltip` | `boolean` | `true` / `true` | 缩放和数值 tooltip 开关 |
|
||||
| `pannable` | `boolean` | `false` | 空格拖拽平移开关 |
|
||||
| `minZoomSpan` | `number` | 未设置 | 最小缩放跨度,使用原始 X 数据单位 |
|
||||
| `minVisiblePoints` | `number` | `0` | 缩放后至少保留的不同 X 坐标数 |
|
||||
| `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围 |
|
||||
| `initialXDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置初始范围 |
|
||||
| `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 |
|
||||
| `yDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置固定范围 |
|
||||
| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 |
|
||||
| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐 |
|
||||
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
|
||||
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
|
||||
| `frameNumber` | `string \| number` | 未设置 | 图框水印内容 |
|
||||
| `zeroLine` | `WaveformZeroLineOptions` | `{ visible: false }` | 零值参考线显隐与样式 |
|
||||
| `cleanView` | `boolean` | `false` | 保留波形、图框和刻度的净图模式 |
|
||||
| `presentationMode` | `boolean` | `false` | 禁用绘图区交互的展示模式 |
|
||||
| `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 |
|
||||
| `annotationsVisible` | `boolean` | `true` | 标注图层显隐 |
|
||||
| `interactionMode` | `'zoom' \| 'annotation'` | `'zoom'` | 左键交互模式 |
|
||||
| `hiddenSeriesIds` | `string[]` | 未设置 | 受控隐藏系列 ID |
|
||||
| `defaultHiddenSeriesIds` | `string[]` | `[]` | 非受控模式的初始隐藏系列 |
|
||||
| Prop | 类型 | 默认值 | 说明 |
|
||||
| --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | ---------------------------------- |
|
||||
| `data` | `WaveformData` | 必填 | 波形数据 |
|
||||
| `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 |
|
||||
| `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 |
|
||||
| `timeUnit` | `'s' \| 'ms'` | `'ms'` | 坐标轴和 tooltip 展示单位 |
|
||||
| `xLabel` / `yLabel` | `string` | `时间(timeUnit)` / `'幅值'` | 坐标轴名称 |
|
||||
| `lineColor` | `string` | `'#0960bd'` | 单波形默认颜色 |
|
||||
| `width` / `height` | `number` | 自适应 | 组件总尺寸,单位为 CSS 像素 |
|
||||
| `zoomable` / `showTooltip` | `boolean` | `true` / `true` | 缩放和数值 tooltip 开关 |
|
||||
| `pannable` | `boolean` | `false` | 空格拖拽平移开关 |
|
||||
| `minZoomSpan` | `number` | 未设置 | 最小缩放跨度,使用原始 X 数据单位 |
|
||||
| `minVisiblePoints` | `number` | `0` | 缩放后至少保留的不同 X 坐标数 |
|
||||
| `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围 |
|
||||
| `initialXDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置初始范围 |
|
||||
| `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 |
|
||||
| `yDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置固定范围 |
|
||||
| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 |
|
||||
| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐与 X 轴 label 格式化 |
|
||||
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
|
||||
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
|
||||
| `frameNumber` | `string \| number` | 未设置 | 图框水印内容 |
|
||||
| `zeroLine` | `WaveformZeroLineOptions` | `{ visible: false }` | 零值参考线显隐与样式 |
|
||||
| `cleanView` | `boolean` | `false` | 保留波形、图框和刻度的净图模式 |
|
||||
| `presentationMode` | `boolean` | `false` | 禁用绘图区交互的展示模式 |
|
||||
| `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 |
|
||||
| `annotationsVisible` | `boolean` | `true` | 标注图层显隐 |
|
||||
| `interactionMode` | `'zoom' \| 'annotation'` | `'zoom'` | 左键交互模式 |
|
||||
| `hiddenSeriesIds` | `string[]` | 未设置 | 受控隐藏系列 ID |
|
||||
| `defaultHiddenSeriesIds` | `string[]` | `[]` | 非受控模式的初始隐藏系列 |
|
||||
|
||||
所有公开类型均可从包入口导入,例如 `WaveformData`、`WaveformSeries`、
|
||||
`WaveformAnnotation`、`WaveformLineStyle`、`WaveformRenderingOptions`、
|
||||
`WaveformAxesOptions`、`WaveformZeroLineOptions`、`WaveformGridOptions` 和
|
||||
`WaveformGridTrackLines`。
|
||||
`WaveformAxesOptions`、`WaveformXAxisLabelFormatter`、`WaveformZeroLineOptions`、
|
||||
`WaveformGridOptions` 和 `WaveformGridTrackLines`。
|
||||
|
||||
### 数据结构
|
||||
|
||||
@@ -508,6 +508,30 @@ scale 定位零线:
|
||||
/>
|
||||
```
|
||||
|
||||
X 轴刻度和左右端点默认先按 `timeUnit` 转换为秒或毫秒,再显示为无千分位、无科学计数法的
|
||||
完整普通十进制值,不会四舍五入为整数。可通过 `axes.x.labelFormatter` 对显示值做运算和格式化:
|
||||
|
||||
```vue
|
||||
<WaveformChart
|
||||
:data="chartData"
|
||||
:axes="{
|
||||
x: {
|
||||
labelFormatter: (value, context) =>
|
||||
`${context.kind === 'tick' ? '' : '[' + context.kind + '] '}${(value / 1000).toFixed(3)}`,
|
||||
},
|
||||
}"
|
||||
/>
|
||||
```
|
||||
|
||||
formatter 首参是已按 `timeUnit` 换算的数值;上下文包含 `kind`(`tick`、`start` 或 `end`)、
|
||||
原始 `rawValue`、`timeUnit`、原始可视域 `domain` 和换算后的 `displayDomain`。formatter 只决定
|
||||
label 文本,不改变刻度位置、源数据、缩放域或事件中的原始 X 坐标。
|
||||
|
||||
Demo 左侧“网格与轴线”支持直接切换数值或固定时间格式。数值模式可设置运算倍率和小数位数;
|
||||
固定时间模式输出 `YYYY-MM-DD HH:mm:ss[.SSS]`,可选择中国标准时间、本地时区或 UTC,并控制
|
||||
是否显示毫秒。时间戳数据仍按组件的秒坐标契约传入,使用默认毫秒显示单位时 formatter 会收到
|
||||
可直接传给 `Date` 的毫秒时间戳。
|
||||
|
||||
`interactionMode` 可选 `zoom` 或 `annotation`,默认使用缩放模式。右键绘图区可直接打开
|
||||
标注编辑器,无需切换交互模式。`zoomable`、`pannable` 和 `showTooltip` 可分别控制缩放、
|
||||
空格拖拽平移和 tooltip;平移默认关闭。
|
||||
@@ -603,7 +627,7 @@ async function importAnnotationFile(file: File) {
|
||||
字段无效时会抛出 `TypeError`,不会返回部分结果。导入包含未知 `seriesId` 的标注是允许的,
|
||||
对应曲线加载后会恢复显示。文件选择、错误提示和下载由业务层实现。
|
||||
|
||||
X、Y 轴会根据各自完整显示域选择格式:最大绝对值在 `[0.01, 100)` 时显示两位普通小数;大于等于 `100`,或大于 `0` 且小于 `0.01` 时,刻度显示两位缩放值,并在轴末端单独显示共享倍率 `E±NN`。X 轴先按 `timeUnit` 转换为秒或毫秒再判断范围,多 Y 轴则分别计算倍率。tooltip 使用最多 4 位小数的本地化普通数字;标注编辑器的 X 坐标跟随 `timeUnit` 并固定 3 位小数,Y 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。
|
||||
X 轴刻度和左右端点先按 `timeUnit` 转换为秒或毫秒,再显示为不带分组符和科学计数法的完整普通十进制值,并可通过 `axes.x.labelFormatter` 自定义。Y 轴会根据完整显示域选择格式:最大绝对值在 `[0.01, 100)` 时显示两位普通小数;大于等于 `100`,或大于 `0` 且小于 `0.01` 时,刻度显示两位缩放值,并在轴末端单独显示共享倍率 `E±NN`,多 Y 轴分别计算倍率。tooltip 使用最多 4 位小数的本地化普通数字并省略无意义尾零;标注编辑器的 X 坐标跟随 `timeUnit` 并固定 3 位小数,Y 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。
|
||||
标注框默认布局在采样点正上方,只做绘图区边界裁剪;文本框通过连接箭头指向标注位置,多个标注重叠时可通过拖动手动避让。
|
||||
|
||||
## 事件
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "waveform-analysis",
|
||||
"version": "0.1.27",
|
||||
"version": "0.1.29",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/types/index.d.ts",
|
||||
|
||||
@@ -23,6 +23,7 @@ import { createSimulatedWaveformData } from './data/simulatedWaveforms'
|
||||
import DemoChartHost from './demo/DemoChartHost.vue'
|
||||
import DemoControlPanel from './demo/DemoControlPanel.vue'
|
||||
import type { DemoChartModel, DemoControlPanelModel } from './demo/types'
|
||||
import { useDemoXAxisLabelControls } from './demo/useDemoXAxisLabelControls'
|
||||
|
||||
const fullChartData = createSimulatedWaveformData()
|
||||
const displayMode = ref<WaveformDisplayMode>('independent')
|
||||
@@ -40,6 +41,7 @@ const verticalGridVisible = ref(true)
|
||||
const verticalGridColor = ref('#dfe5ef')
|
||||
const xAxisLineVisible = ref(false)
|
||||
const yAxisLineVisible = ref(false)
|
||||
const { controlModel: xAxisLabelControlModel, xAxisLabelFormatter } = useDemoXAxisLabelControls()
|
||||
const annotations = ref<WaveformAnnotation[]>([])
|
||||
const annotationsVisible = ref(true)
|
||||
const cleanView = ref(false)
|
||||
@@ -112,7 +114,10 @@ const frameStyle = computed<WaveformFrameStyle>(() => ({
|
||||
backgroundColor: frameBackgroundColor.value,
|
||||
}))
|
||||
const axes = computed<WaveformAxesOptions>(() => ({
|
||||
x: { lineVisible: xAxisLineVisible.value },
|
||||
x: {
|
||||
lineVisible: xAxisLineVisible.value,
|
||||
...(xAxisLabelFormatter.value ? { labelFormatter: xAxisLabelFormatter.value } : {}),
|
||||
},
|
||||
y: { lineVisible: yAxisLineVisible.value },
|
||||
}))
|
||||
const zeroLine = computed<WaveformZeroLineOptions>(() => ({
|
||||
@@ -308,6 +313,7 @@ const controlPanelModel = reactive({
|
||||
verticalGridColor,
|
||||
xAxisLineVisible,
|
||||
yAxisLineVisible,
|
||||
...xAxisLabelControlModel,
|
||||
frameBorderColor,
|
||||
frameBackgroundColor,
|
||||
frameBorderWidth,
|
||||
|
||||
@@ -192,7 +192,7 @@ describe('multi-value Y-axis grouping', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('places left and right scientific exponents eight pixels outside their tick labels', () => {
|
||||
it('reserves label clearance for exponent-prefixed ticks on both axis sides', () => {
|
||||
const layout = buildTrackLayouts({
|
||||
cells: [
|
||||
{
|
||||
@@ -206,7 +206,7 @@ describe('multi-value Y-axis grouping', () => {
|
||||
plotHeight: 300,
|
||||
cellHeight: 330,
|
||||
xAxisBand: 30,
|
||||
series: track([series('left', 0, 254), series('right', 0, 254)]),
|
||||
series: track([series('left', 1000, 3000), series('right', 1000, 3000)]),
|
||||
},
|
||||
],
|
||||
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
|
||||
@@ -222,14 +222,13 @@ describe('multi-value Y-axis grouping', () => {
|
||||
})[0]
|
||||
|
||||
expect(
|
||||
layout?.yAxes.map(({ side, x, exponentX, exponentLabel }) => ({
|
||||
layout?.yAxes.map(({ side, x, labelX }) => ({
|
||||
side,
|
||||
offset: Math.abs(exponentX - x),
|
||||
exponentLabel,
|
||||
labelOffset: Math.abs(labelX - x),
|
||||
})),
|
||||
).toEqual([
|
||||
{ side: 'left', offset: 43, exponentLabel: 'E+02' },
|
||||
{ side: 'right', offset: 43, exponentLabel: 'E+02' },
|
||||
{ side: 'left', labelOffset: 67 },
|
||||
{ side: 'right', labelOffset: 67 },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -237,7 +236,7 @@ describe('multi-value Y-axis grouping', () => {
|
||||
const [group] = buildYAxisSeriesGroups(track([series('long', -1e120, 1e120)]), 'multi-axis')
|
||||
|
||||
expect(group).toBeDefined()
|
||||
expect(measureYAxisGroupClearance(group!)).toBe(119)
|
||||
expect(measureYAxisGroupClearance(group!)).toBe(90)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { scaleLinear } from 'd3'
|
||||
|
||||
import type { WaveformOverlayMode } from '../../types'
|
||||
import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../../utils'
|
||||
import { formatScientificAxisLabel, paddedDomain } from '../../utils'
|
||||
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
|
||||
import { MAX_MULTI_Y_AXIS_COUNT, Y_AXIS_EXPONENT_GAP } from './constants'
|
||||
import { MAX_MULTI_Y_AXIS_COUNT } from './constants'
|
||||
import {
|
||||
mergeYDomains,
|
||||
resolveSeriesFixedYDomain,
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from './yDomain'
|
||||
|
||||
// 导出常量供外部使用
|
||||
export { MAX_MULTI_Y_AXIS_COUNT, Y_AXIS_EXPONENT_GAP } from './constants'
|
||||
export { MAX_MULTI_Y_AXIS_COUNT } from './constants'
|
||||
|
||||
const Y_AXIS_CHARACTER_WIDTH = 7
|
||||
const Y_AXIS_TICK_PADDING = 7
|
||||
@@ -102,36 +102,27 @@ export function resolveYAxisSeriesGroups(
|
||||
export function axisTextMetrics(
|
||||
domain: [number, number],
|
||||
nice = true,
|
||||
): {
|
||||
exponentLabel: string | null
|
||||
exponentWidth: number
|
||||
tickTextWidth: number
|
||||
} {
|
||||
tickValues?: number[],
|
||||
): { tickTextWidth: number } {
|
||||
const scale = scaleLinear(domain, [1, 0])
|
||||
if (nice) scale.nice()
|
||||
const [axisMin, axisMax] = scale.domain()
|
||||
const values = scale.ticks(10)
|
||||
const values = tickValues ?? Array.from(new Set([axisMin, ...scale.ticks(10), axisMax]))
|
||||
const topTickValue = Math.max(...values)
|
||||
const maximumTickCharacters = Math.max(
|
||||
1,
|
||||
...values.map((value) => formatScientificAxisLabel(value, { axisMin, axisMax }).length),
|
||||
...values.map(
|
||||
(value) => formatScientificAxisLabel(value, { axisMin, axisMax, topTickValue }).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], nice: boolean): number {
|
||||
const { exponentLabel, exponentWidth } = axisTextMetrics(domain, nice)
|
||||
return exponentLabel ? exponentWidth + Y_AXIS_EXPONENT_GAP : 0
|
||||
}
|
||||
|
||||
export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
|
||||
return (
|
||||
axisTextMetrics(group.domain, !group.fixed).tickTextWidth +
|
||||
axisExponentClearance(group.domain, !group.fixed) +
|
||||
Y_AXIS_TICK_PADDING +
|
||||
Y_AXIS_LABEL_GAP +
|
||||
Y_AXIS_LABEL_BAND_WIDTH +
|
||||
@@ -142,7 +133,6 @@ export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
|
||||
function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number {
|
||||
return (
|
||||
axisTextMetrics(group.domain, !group.fixed).tickTextWidth +
|
||||
axisExponentClearance(group.domain, !group.fixed) +
|
||||
Y_AXIS_TICK_PADDING +
|
||||
Y_AXIS_OUTER_PADDING
|
||||
)
|
||||
|
||||
@@ -12,8 +12,13 @@ import {
|
||||
selectSeriesRenderPoints,
|
||||
type ResolvedWaveformRenderingOptions,
|
||||
} from '../../core/rendering'
|
||||
import type { WaveformDisplayMode, WaveformOverlayMode, WaveformPoint } from '../../types'
|
||||
import { buildMinorTicks, formatAxisTimeExponent, formatEndpointTime } from '../../utils'
|
||||
import type {
|
||||
WaveformDisplayMode,
|
||||
WaveformOverlayMode,
|
||||
WaveformPoint,
|
||||
WaveformXAxisLabelFormatter,
|
||||
} from '../../types'
|
||||
import { buildMinorTicks, formatXAxisLabel } from '../../utils'
|
||||
import {
|
||||
getBottomRowCellIndexes,
|
||||
type GridCellGeometry,
|
||||
@@ -21,7 +26,6 @@ import {
|
||||
} from './grid'
|
||||
import { axisTextMetrics, resolveYAxisSeriesGroups } from './layout'
|
||||
import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
|
||||
import { Y_AXIS_EXPONENT_GAP } from './constants'
|
||||
import {
|
||||
Y_AXIS_LABEL_BAND_WIDTH,
|
||||
Y_AXIS_LABEL_GAP,
|
||||
@@ -46,6 +50,7 @@ export interface BuildTrackLayoutsOptions {
|
||||
fixedYDomains?: Record<string, [number, number]>
|
||||
yDomains?: Record<string, [number, number]>
|
||||
timeUnit: 's' | 'ms'
|
||||
xAxisLabelFormatter?: WaveformXAxisLabelFormatter
|
||||
rendering: ResolvedWaveformRenderingOptions
|
||||
hideSecondaryLabels: boolean
|
||||
yAxisLabelX: number
|
||||
@@ -118,31 +123,16 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
const tickValues = Array.from(
|
||||
new Set([axisStart, ...visibleMajorTicks, ...(showAxisEnd ? [axisEnd] : [])]),
|
||||
)
|
||||
const { exponentLabel, exponentWidth, tickTextWidth } = axisTextMetrics(
|
||||
group.domain,
|
||||
!group.fixed,
|
||||
)
|
||||
const exponentClearance = exponentLabel ? exponentWidth + Y_AXIS_EXPONENT_GAP : 0
|
||||
const { tickTextWidth } = axisTextMetrics(group.domain, !group.fixed, tickValues)
|
||||
const clearance =
|
||||
tickTextWidth +
|
||||
Y_AXIS_TICK_PADDING +
|
||||
exponentClearance +
|
||||
Y_AXIS_LABEL_GAP +
|
||||
Y_AXIS_LABEL_BAND_WIDTH +
|
||||
Y_AXIS_OUTER_PADDING
|
||||
const x = group.side === 'left' ? -sideOffsets.left : cell.width + sideOffsets.right
|
||||
const exponentX =
|
||||
x +
|
||||
(group.side === 'left'
|
||||
? -(Y_AXIS_TICK_PADDING + tickTextWidth + Y_AXIS_EXPONENT_GAP)
|
||||
: Y_AXIS_TICK_PADDING + tickTextWidth + Y_AXIS_EXPONENT_GAP)
|
||||
const labelDistance =
|
||||
tickTextWidth +
|
||||
Y_AXIS_TICK_PADDING +
|
||||
exponentClearance +
|
||||
exponentWidth +
|
||||
Y_AXIS_LABEL_GAP +
|
||||
Y_AXIS_LABEL_BAND_WIDTH / 2
|
||||
tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_BAND_WIDTH / 2
|
||||
const labelX = x + (group.side === 'left' ? -labelDistance : labelDistance)
|
||||
sideOffsets[group.side] += clearance
|
||||
return {
|
||||
@@ -150,8 +140,6 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
side: group.side,
|
||||
x,
|
||||
labelX,
|
||||
exponentX,
|
||||
exponentLabel,
|
||||
scale,
|
||||
majorTicks,
|
||||
minorTicks: buildMinorTicks(majorTicks),
|
||||
@@ -165,10 +153,21 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
const yAxisTickValues = yAxes[0]?.tickValues ?? []
|
||||
const domain = xScale.domain() as [number, number]
|
||||
const endpointLabels = {
|
||||
start: formatEndpointTime(domain[0], domain, options.timeUnit),
|
||||
end: formatEndpointTime(domain[1], domain, options.timeUnit),
|
||||
start: formatXAxisLabel(
|
||||
domain[0],
|
||||
domain,
|
||||
options.timeUnit,
|
||||
'start',
|
||||
options.xAxisLabelFormatter,
|
||||
),
|
||||
end: formatXAxisLabel(
|
||||
domain[1],
|
||||
domain,
|
||||
options.timeUnit,
|
||||
'end',
|
||||
options.xAxisLabelFormatter,
|
||||
),
|
||||
}
|
||||
const xAxisExponent = formatAxisTimeExponent(domain, options.timeUnit)
|
||||
const leftClearance = endpointLabels.start.length * 7 + 10
|
||||
const rightClearance = endpointLabels.end.length * 7 + 10
|
||||
const xAxisTickValues = xMajorTicks.filter((tick) => {
|
||||
@@ -235,7 +234,6 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
yAxisTickValues,
|
||||
xAxisTickValues,
|
||||
endpointLabels,
|
||||
xAxisExponent,
|
||||
path: seriesPaths[0]?.path ?? null,
|
||||
seriesPaths,
|
||||
gridLines: options.grid.trackLines[displayTrack.id] ?? {
|
||||
|
||||
@@ -51,8 +51,6 @@ export interface WaveformYAxisLayout {
|
||||
side: 'left' | 'right'
|
||||
x: number
|
||||
labelX: number
|
||||
exponentX: number
|
||||
exponentLabel: string | null
|
||||
scale: ScaleLinear<number, number>
|
||||
majorTicks: number[]
|
||||
minorTicks: number[]
|
||||
@@ -109,7 +107,6 @@ export interface TrackLayout {
|
||||
yAxisTickValues: number[]
|
||||
xAxisTickValues: number[]
|
||||
endpointLabels: { start: string; end: string }
|
||||
xAxisExponent: string | null
|
||||
path: string | null
|
||||
seriesPaths: TrackSeriesPath[]
|
||||
showXAxis: boolean
|
||||
|
||||
@@ -2,7 +2,7 @@ import { scaleLinear, type ZoomTransform } from 'd3'
|
||||
import { computed, type ComputedRef, type Ref, type ShallowRef } from 'vue'
|
||||
|
||||
import { resolveWaveformRenderingOptions } from '../../core'
|
||||
import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../../utils'
|
||||
import { paddedDomain } from '../../utils'
|
||||
import {
|
||||
layoutAnnotations,
|
||||
type AnnotationSeriesInfo,
|
||||
@@ -28,9 +28,9 @@ import {
|
||||
} from './grid'
|
||||
import {
|
||||
buildTrackLayouts,
|
||||
axisTextMetrics,
|
||||
measureTrackYAxisClearance,
|
||||
resolveYAxisSeriesGroups,
|
||||
Y_AXIS_EXPONENT_GAP,
|
||||
} from './layout'
|
||||
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
|
||||
import type { PreparedWaveformSeries } from './useWaveformData'
|
||||
@@ -112,40 +112,18 @@ export function useWaveformLayout(context: LayoutContext) {
|
||||
.flatMap((track) =>
|
||||
resolveYAxisSeriesGroups(track, props.overlayMode, props.yDomain, props.yDomains),
|
||||
)
|
||||
.map((group) => {
|
||||
const scale = scaleLinear(group.domain, [1, 0])
|
||||
if (!group.fixed) scale.nice()
|
||||
const [axisMin, axisMax] = scale.domain()
|
||||
return {
|
||||
exponentLabel: formatScientificAxisExponent(axisMin, axisMax),
|
||||
tickLabels: scale
|
||||
.ticks(10)
|
||||
.map((value) => formatScientificAxisLabel(value, { axisMin, axisMax })),
|
||||
}
|
||||
})
|
||||
const maximumCharacters = Math.max(
|
||||
1,
|
||||
...axisText.flatMap(({ tickLabels }) => tickLabels).map((label) => label.length),
|
||||
)
|
||||
const tickTextWidth = maximumCharacters * Y_AXIS_CHARACTER_WIDTH
|
||||
const maximumExponentWidth = Math.max(
|
||||
0,
|
||||
...axisText.map(({ exponentLabel }) => (exponentLabel?.length ?? 0) * Y_AXIS_CHARACTER_WIDTH),
|
||||
)
|
||||
const exponentClearance = maximumExponentWidth ? maximumExponentWidth + Y_AXIS_EXPONENT_GAP : 0
|
||||
const tickClearance =
|
||||
tickTextWidth + Y_AXIS_TICK_PADDING + exponentClearance + Y_AXIS_OUTER_PADDING
|
||||
.map((group) => axisTextMetrics(group.domain, !group.fixed).tickTextWidth)
|
||||
const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText)
|
||||
const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING
|
||||
const labelCenterX = -(
|
||||
Y_AXIS_TICK_PADDING +
|
||||
tickTextWidth +
|
||||
exponentClearance +
|
||||
Y_AXIS_LABEL_GAP +
|
||||
Y_AXIS_LABEL_BAND_WIDTH / 2
|
||||
)
|
||||
const fullClearance =
|
||||
tickTextWidth +
|
||||
Y_AXIS_TICK_PADDING +
|
||||
exponentClearance +
|
||||
Y_AXIS_LABEL_GAP +
|
||||
Y_AXIS_LABEL_BAND_WIDTH +
|
||||
Y_AXIS_OUTER_PADDING
|
||||
@@ -307,6 +285,7 @@ export function useWaveformLayout(context: LayoutContext) {
|
||||
)
|
||||
: sharedYDomains.value,
|
||||
timeUnit: props.timeUnit,
|
||||
xAxisLabelFormatter: props.axes?.x?.labelFormatter,
|
||||
rendering: renderingOptions.value,
|
||||
hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
|
||||
yAxisLabelX: yAxisMetrics.value.labelCenterX,
|
||||
|
||||
@@ -91,6 +91,22 @@ describe('WaveformTooltip', () => {
|
||||
expect(wrapper.get('.waveform-tooltip__value').text()).toBe('-1,405.4932 A')
|
||||
})
|
||||
|
||||
it('formats tooltip time with at most four decimal places', () => {
|
||||
const wrapper = mount(WaveformTooltip, {
|
||||
props: {
|
||||
visible: true,
|
||||
position: { x: 10, y: 10 },
|
||||
timeUnit: 's',
|
||||
hoveredPoint: { x: 1.234567, y: 12 },
|
||||
seriesPoints: [],
|
||||
containerWidth: 400,
|
||||
containerHeight: 300,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.get('.waveform-tooltip__time').text()).toBe('s: 1.2346')
|
||||
})
|
||||
|
||||
it('omits the error label when both resolved errors are zero', () => {
|
||||
const point = { x: 1, y: 12 }
|
||||
const wrapper = mount(WaveformTooltip, {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { axisBottom, axisLeft, axisRight, select } from 'd3'
|
||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { WaveformAxesOptions } from '../../types'
|
||||
import { formatAxisTime, formatScientificAxisLabel } from '../../utils'
|
||||
import { formatScientificAxisLabel, formatXAxisLabel } from '../../utils'
|
||||
import type { DisplaySeries, TrackLayout, WaveformYAxisLayout } from '../core/types'
|
||||
|
||||
interface Props {
|
||||
@@ -43,8 +43,11 @@ function renderAxes() {
|
||||
const element = yAxisElements.value[index]
|
||||
if (!element) return
|
||||
const [axisMin, axisMax] = axis.scale.domain()
|
||||
const topTickValue = Math.max(...axis.tickValues)
|
||||
const yAxis = (axis.side === 'left' ? axisLeft(axis.scale) : axisRight(axis.scale))
|
||||
.tickFormat((value) => formatScientificAxisLabel(Number(value), { axisMin, axisMax }))
|
||||
.tickFormat((value) =>
|
||||
formatScientificAxisLabel(Number(value), { axisMin, axisMax, topTickValue }),
|
||||
)
|
||||
.tickSize(-4)
|
||||
.tickPadding(7)
|
||||
.tickSizeOuter(0)
|
||||
@@ -63,10 +66,12 @@ function renderAxes() {
|
||||
axisBottom(props.track.xScale)
|
||||
.tickValues(props.track.xAxisTickValues)
|
||||
.tickFormat((value) =>
|
||||
formatAxisTime(
|
||||
formatXAxisLabel(
|
||||
Number(value),
|
||||
props.timeUnit,
|
||||
props.track.xScale.domain() as [number, number],
|
||||
props.timeUnit,
|
||||
'tick',
|
||||
props.axes?.x?.labelFormatter,
|
||||
),
|
||||
)
|
||||
.tickSize(-4)
|
||||
@@ -90,6 +95,7 @@ watch(
|
||||
() => props.track.xAxisTickValues,
|
||||
() => props.track.yAxisTickValues,
|
||||
() => props.timeUnit,
|
||||
() => props.axes?.x?.labelFormatter,
|
||||
() => props.axes?.x?.lineVisible,
|
||||
() => props.axes?.y?.lineVisible,
|
||||
],
|
||||
@@ -136,17 +142,6 @@ watch(
|
||||
{{ track.endpointLabels.end }}
|
||||
</text>
|
||||
</g>
|
||||
<text
|
||||
v-if="track.showXAxis && track.xAxisExponent"
|
||||
class="waveform-track__axis-exponent waveform-track__axis-exponent--x waveform-chart__axis-exponent waveform-chart__axis-exponent--x"
|
||||
:x="track.width ?? innerWidth"
|
||||
:y="track.height + 27"
|
||||
text-anchor="end"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ track.xAxisExponent }}
|
||||
</text>
|
||||
|
||||
<g
|
||||
v-for="axis in track.isEmpty ? [] : track.yAxes"
|
||||
:key="`y-axis-${track.index}-${axis.index}`"
|
||||
@@ -160,20 +155,6 @@ watch(
|
||||
:data-y-axis-side="axis.side"
|
||||
:transform="`translate(${axis.x}, 0)`"
|
||||
/>
|
||||
<text
|
||||
v-for="axis in track.isEmpty ? [] : track.yAxes.filter((item) => item.exponentLabel)"
|
||||
:key="`y-axis-exponent-${track.index}-${axis.index}`"
|
||||
class="waveform-track__axis-exponent waveform-track__axis-exponent--y waveform-chart__axis-exponent waveform-chart__axis-exponent--y"
|
||||
:data-y-axis-index="axis.index"
|
||||
:x="axis.exponentX"
|
||||
y="0"
|
||||
dy="0.32em"
|
||||
:text-anchor="axis.side === 'left' ? 'end' : 'start'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ axis.exponentLabel }}
|
||||
</text>
|
||||
|
||||
<g
|
||||
v-if="
|
||||
!cleanView &&
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('WaveformChart', () => {
|
||||
expect(endTicks[3]).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps one separate shared exponent for every compact Y axis', async () => {
|
||||
it('prefixes one shared exponent to the largest visible tick on every compact Y axis', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'series',
|
||||
@@ -83,7 +83,7 @@ describe('WaveformChart', () => {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 254 },
|
||||
{ x: 1, y: 2540 },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -103,14 +103,14 @@ describe('WaveformChart', () => {
|
||||
)
|
||||
|
||||
const axes = wrapper.findAll('.waveform-chart__axis--y')
|
||||
const exponents = wrapper.findAll('.waveform-chart__axis-exponent--y')
|
||||
expect(axes).toHaveLength(2)
|
||||
expect(exponents.map((label) => label.text())).toEqual(['E+02', 'E-04'])
|
||||
axes.forEach((axis) => {
|
||||
axes.forEach((axis, index) => {
|
||||
const labels = axis.findAll('.tick text').map((tick) => tick.text())
|
||||
expect(labels.every((label) => !label.startsWith('E'))).toBe(true)
|
||||
expect(labels.every((label) => /^-?\d+\.\d{2}$/.test(label))).toBe(true)
|
||||
const exponentLabels = labels.filter((label) => label.startsWith('E'))
|
||||
expect(exponentLabels).toHaveLength(1)
|
||||
expect(exponentLabels[0]).toMatch(index === 0 ? /^E\+03 / : /^E-04 /)
|
||||
})
|
||||
expect(wrapper.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers a trimmed series name and falls back to yLabel for unnamed data', async () => {
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('WaveformChart', () => {
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1, y: 5 }])
|
||||
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
|
||||
expect(wrapper.get('.waveform-chart__tooltip').text()).toContain('ms: 1,000.0000')
|
||||
expect(wrapper.get('.waveform-chart__tooltip-time').text()).toBe('ms: 1,000')
|
||||
const crosshairLines = wrapper.findAll('.waveform-chart__crosshair line')
|
||||
expect(crosshairLines).toHaveLength(1)
|
||||
expect(crosshairLines[0].attributes('x1')).toBe(crosshairLines[0].attributes('x2'))
|
||||
@@ -303,7 +303,7 @@ describe('WaveformChart', () => {
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('.waveform-chart__tooltip').text()).toContain('ms: 1,000.0000')
|
||||
expect(wrapper.get('.waveform-chart__tooltip-time').text()).toBe('ms: 1,000')
|
||||
expect(wrapper.findAll('.waveform-chart__crosshair line')).toHaveLength(1)
|
||||
expect(wrapper.get('.waveform-chart__line').element).toBe(pathBeforeHover)
|
||||
expect(chartUpdate).not.toHaveBeenCalled()
|
||||
|
||||
@@ -62,15 +62,15 @@ describe('WaveformChart', () => {
|
||||
],
|
||||
}
|
||||
const wrapper = await mountSizedChart(firstData)
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
|
||||
|
||||
firstData.points.push({ x: 2, y: 2 })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
|
||||
|
||||
await wrapper.setProps({ data: { ...firstData, points: [...firstData.points] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
|
||||
})
|
||||
|
||||
it('keeps controlled annotations when replacing the loaded data window', async () => {
|
||||
@@ -170,7 +170,7 @@ describe('WaveformChart', () => {
|
||||
expect(wrapper.findAll('.waveform-chart__track-label')).toHaveLength(0)
|
||||
expect(
|
||||
wrapper.findAll('.waveform-chart__axis-endpoint--end').map((item) => item.text()),
|
||||
).toEqual(['1.00', '2.00'])
|
||||
).toEqual(['1000', '2000'])
|
||||
})
|
||||
|
||||
it('keeps the zero Y-axis label on upper compact tracks', async () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { flushAnimationFrames } from '../../test/setup'
|
||||
import type { WaveformXAxisLabelFormatter } from '../../types'
|
||||
import { type WaveformData } from '../waveform'
|
||||
|
||||
import { gridSeries, mountSizedChart } from '../../test/waveformChart'
|
||||
@@ -162,35 +163,42 @@ describe('WaveformChart', () => {
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').attributes('x')).toBe(
|
||||
String(trackWidth),
|
||||
)
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4.99')
|
||||
expect(wrapper.get('.waveform-chart__axis-exponent--x').text()).toBe('E+03')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4990.3')
|
||||
expect(wrapper.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('uses one shared scientific exponent only for large and tiny Y-axis domains', async () => {
|
||||
const cases = [
|
||||
{ values: [0, 50], exponent: null },
|
||||
{ values: [0, 254], exponent: 'E+02' },
|
||||
{ values: [0, 0.0002], exponent: 'E-04' },
|
||||
{ values: [0, 50], yDomain: [0, 50] as [number, number], exponent: null },
|
||||
{ values: [1000, 3000], yDomain: [1000, 3000] as [number, number], exponent: 'E+03' },
|
||||
{
|
||||
values: [0.0001, 0.0003],
|
||||
yDomain: [0.0001, 0.0003] as [number, number],
|
||||
exponent: 'E-04',
|
||||
},
|
||||
]
|
||||
|
||||
for (const { values, exponent } of cases) {
|
||||
const wrapper = await mountSizedChart({
|
||||
kind: 'points',
|
||||
for (const { values, yDomain, exponent } of cases) {
|
||||
const data = {
|
||||
kind: 'points' as const,
|
||||
points: values.map((y, x) => ({ x, y })),
|
||||
})
|
||||
}
|
||||
const originalValues = data.points.map((point) => point.y)
|
||||
const wrapper = await mountSizedChart(data, { yDomain })
|
||||
const labels = wrapper
|
||||
.get('.waveform-chart__axis--y')
|
||||
.findAll('.tick text')
|
||||
.map((tick) => tick.text())
|
||||
const exponentLabel = wrapper.find('.waveform-chart__axis-exponent--y')
|
||||
const exponentLabels = labels.filter((label) => label.startsWith('E'))
|
||||
|
||||
if (exponent === null) {
|
||||
expect(exponentLabel.exists()).toBe(false)
|
||||
expect(exponentLabels).toHaveLength(0)
|
||||
} else {
|
||||
expect(exponentLabel.text()).toBe(exponent)
|
||||
expect(labels.every((label) => !label.startsWith('E'))).toBe(true)
|
||||
expect(labels.every((label) => /^-?\d+\.\d{2}$/.test(label))).toBe(true)
|
||||
expect(exponentLabels).toHaveLength(1)
|
||||
expect(exponentLabels[0]).toMatch(new RegExp(`^${exponent.replace('+', '\\+')} `))
|
||||
}
|
||||
expect(wrapper.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
|
||||
expect(data.points.map((point) => point.y)).toEqual(originalValues)
|
||||
|
||||
wrapper.unmount()
|
||||
}
|
||||
@@ -212,13 +220,13 @@ describe('WaveformChart', () => {
|
||||
|
||||
expect(millisecondsChart.text()).toContain('时间(ms)')
|
||||
expect(millisecondTicks.length).toBeGreaterThan(0)
|
||||
expect(millisecondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
|
||||
expect(millisecondsChart.get('.waveform-chart__axis-exponent--x').text()).toBe('E+03')
|
||||
expect(millisecondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
|
||||
expect(millisecondsChart.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
|
||||
expect(millisecondsChart.find('.waveform-chart__watermark').exists()).toBe(false)
|
||||
|
||||
const secondsChart = await mountSizedChart(data, { timeUnit: 's', xLabel: 'Elapsed time' })
|
||||
expect(secondsChart.text()).toContain('Elapsed time')
|
||||
expect(secondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
|
||||
expect(secondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1')
|
||||
expect(secondsChart.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -237,15 +245,15 @@ describe('WaveformChart', () => {
|
||||
|
||||
expect(start.attributes('x')).toBe('0')
|
||||
expect(start.attributes('text-anchor')).toBe('start')
|
||||
expect(start.text()).toBe('0.00')
|
||||
expect(start.text()).toBe('0')
|
||||
expect(end.attributes('x')).toBe(
|
||||
wrapper.get('.waveform-chart__track').attributes('data-track-width'),
|
||||
)
|
||||
expect(end.attributes('text-anchor')).toBe('end')
|
||||
expect(end.text()).toBe('2.00')
|
||||
expect(end.text()).toBe('1999')
|
||||
expect(middleTickLabels.length).toBeGreaterThan(0)
|
||||
expect(middleTickLabels).not.toContain('0.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-exponent--x').text()).toBe('E+03')
|
||||
expect(middleTickLabels).not.toContain('0')
|
||||
expect(wrapper.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.waveform-chart__grid--major line').length).toBeGreaterThan(
|
||||
middleTickLabels.length,
|
||||
)
|
||||
@@ -260,6 +268,70 @@ describe('WaveformChart', () => {
|
||||
expect(endpointGroup.attributes('font-size')).toBe(xAxis.attributes('font-size'))
|
||||
})
|
||||
|
||||
it('formats X-axis ticks and endpoints with display-unit values and source context', async () => {
|
||||
const data: WaveformData = {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0.125, y: 0 },
|
||||
{ x: 1.875, y: 1 },
|
||||
],
|
||||
}
|
||||
const originalPoints = data.points.map((point) => ({ ...point }))
|
||||
const labelFormatter: WaveformXAxisLabelFormatter = (value, context) =>
|
||||
`${context.kind}:${value / 10}`
|
||||
const formatter = vi.fn(labelFormatter)
|
||||
const wrapper = await mountSizedChart(data, {
|
||||
timeUnit: 'ms',
|
||||
axes: { x: { labelFormatter: formatter } },
|
||||
})
|
||||
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('start:12.5')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('end:187.5')
|
||||
expect(
|
||||
wrapper
|
||||
.get('.waveform-chart__axis--x')
|
||||
.findAll('.tick text')
|
||||
.every((tick) => tick.text().startsWith('tick:')),
|
||||
).toBe(true)
|
||||
|
||||
const startCall = formatter.mock.calls.find(([, context]) => context.kind === 'start')
|
||||
const tickCall = formatter.mock.calls.find(([, context]) => context.kind === 'tick')
|
||||
const endCall = formatter.mock.calls.find(([, context]) => context.kind === 'end')
|
||||
expect(startCall).toEqual([
|
||||
125,
|
||||
{
|
||||
kind: 'start',
|
||||
rawValue: 0.125,
|
||||
timeUnit: 'ms',
|
||||
domain: [0.125, 1.875],
|
||||
displayDomain: [125, 1875],
|
||||
},
|
||||
])
|
||||
expect(tickCall?.[0]).toBeTypeOf('number')
|
||||
expect(tickCall?.[1]).toMatchObject({
|
||||
kind: 'tick',
|
||||
timeUnit: 'ms',
|
||||
domain: [0.125, 1.875],
|
||||
displayDomain: [125, 1875],
|
||||
})
|
||||
expect(endCall?.[1]).toMatchObject({ kind: 'end', rawValue: 1.875 })
|
||||
expect(data.points).toEqual(originalPoints)
|
||||
|
||||
await wrapper.setProps({
|
||||
axes: { x: { labelFormatter: (value: number) => `updated:${value}` } },
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('updated:125')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('updated:1875')
|
||||
expect(
|
||||
wrapper
|
||||
.get('.waveform-chart__axis--x')
|
||||
.findAll('.tick text')
|
||||
.every((tick) => tick.text().startsWith('updated:')),
|
||||
).toBe(true)
|
||||
expect(data.points).toEqual(originalPoints)
|
||||
})
|
||||
|
||||
it('keeps zoom-change domains in source seconds', async () => {
|
||||
const wrapper = await mountSizedChart({
|
||||
kind: 'points',
|
||||
@@ -300,6 +372,8 @@ describe('WaveformChart', () => {
|
||||
)
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe(initialStart)
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).not.toBe(initialEnd)
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toContain('.')
|
||||
expect(
|
||||
Number.isFinite(Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('WaveformChart', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.waveform-chart__axis--x')).toHaveLength(1)
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
|
||||
})
|
||||
|
||||
it('updates rendering props and disables zoom interaction', async () => {
|
||||
|
||||
@@ -33,10 +33,10 @@ describe('WaveformChart', () => {
|
||||
tracks[0].get('.waveform-chart__y-axis-label-bg').attributes('x'),
|
||||
)
|
||||
|
||||
expect(labelX).toBe(-103)
|
||||
expect(labelX).toBe(-74)
|
||||
expect(labelBackgroundX).toBe(labelX - 12)
|
||||
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(119)
|
||||
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(119)
|
||||
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(90)
|
||||
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(90)
|
||||
})
|
||||
|
||||
it('keeps a tick-only gutter when channel labels are empty', async () => {
|
||||
@@ -62,8 +62,8 @@ describe('WaveformChart', () => {
|
||||
const secondLeft = Number(tracks[1].attributes('data-track-left'))
|
||||
|
||||
expect(wrapper.findAll('.waveform-chart__y-axis-label')).toHaveLength(0)
|
||||
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(89)
|
||||
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(89)
|
||||
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(64)
|
||||
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(60)
|
||||
})
|
||||
|
||||
it('keeps the Y-axis label gutter stable while paging between value ranges', async () => {
|
||||
@@ -122,7 +122,7 @@ describe('WaveformChart', () => {
|
||||
const tracks = wrapper.findAll('.waveform-chart__track')
|
||||
const firstWidth = Number(tracks[0].attributes('data-track-width'))
|
||||
const secondLeft = Number(tracks[1].attributes('data-track-left'))
|
||||
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(39)
|
||||
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(32)
|
||||
})
|
||||
|
||||
it('uses one shared overlay and bottom-row x axes for separated and compact grids', async () => {
|
||||
@@ -262,10 +262,10 @@ describe('WaveformChart', () => {
|
||||
)
|
||||
|
||||
const tracks = wrapper.findAll('.waveform-chart__track')
|
||||
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8.00')
|
||||
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5.00')
|
||||
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8.00')
|
||||
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5.00')
|
||||
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8000')
|
||||
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000')
|
||||
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8000')
|
||||
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000')
|
||||
})
|
||||
|
||||
it('uses an explicit initial x domain override for an independent track', async () => {
|
||||
@@ -293,7 +293,7 @@ describe('WaveformChart', () => {
|
||||
},
|
||||
)
|
||||
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
|
||||
})
|
||||
|
||||
it('reacts to exact fixed Y-domain props and returns to automatic bounds', async () => {
|
||||
@@ -312,13 +312,13 @@ describe('WaveformChart', () => {
|
||||
|
||||
await wrapper.setProps({ yDomain: [3, 97] })
|
||||
await flushPromises()
|
||||
expect(yTickLabels()).toContain('3.00')
|
||||
expect(yTickLabels()).toContain('97.00')
|
||||
expect(yTickLabels()).toContain('3')
|
||||
expect(yTickLabels()).toContain('97')
|
||||
|
||||
await wrapper.setProps({ yDomain: undefined })
|
||||
await flushPromises()
|
||||
expect(yTickLabels()).not.toContain('3.00')
|
||||
expect(yTickLabels()).not.toContain('97.00')
|
||||
expect(yTickLabels()).not.toContain('3')
|
||||
expect(yTickLabels()).not.toContain('97')
|
||||
})
|
||||
|
||||
it('keeps annotations bound to their channel while paging', async () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('WaveformChart', () => {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 254 },
|
||||
{ x: 1, y: 2540 },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -51,13 +51,13 @@ describe('WaveformChart', () => {
|
||||
)
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.findAll('.waveform-chart__axis-exponent--y')
|
||||
.map((label) => [label.attributes('data-y-axis-index'), label.text()]),
|
||||
).toEqual([
|
||||
['1', 'E+02'],
|
||||
['2', 'E-04'],
|
||||
])
|
||||
wrapper.findAll('.waveform-chart__axis--y').map((axis) =>
|
||||
axis
|
||||
.findAll('.tick text')
|
||||
.map((label) => label.text())
|
||||
.filter((label) => label.startsWith('E')),
|
||||
),
|
||||
).toEqual([[], [expect.stringMatching(/^E\+03 /)], [expect.stringMatching(/^E-04 /)]])
|
||||
})
|
||||
|
||||
it('reprojects annotations with the Y axis assigned to their series', async () => {
|
||||
|
||||
@@ -54,8 +54,9 @@ describe('WaveformChart', () => {
|
||||
],
|
||||
})
|
||||
|
||||
expect(first.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
|
||||
expect(second.get('.waveform-chart__axis-exponent--y').text()).toBe('E+04')
|
||||
expect(first.get('.waveform-chart__axis--y').text()).not.toContain('E')
|
||||
expect(second.get('.waveform-chart__axis--y').text()).toContain('E+04 ')
|
||||
expect(second.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders a configurable zero line only when the Y domain contains zero', async () => {
|
||||
@@ -299,8 +300,9 @@ describe('WaveformChart', () => {
|
||||
})
|
||||
expect(xAxis.get('path.domain').attributes('display')).toBe('none')
|
||||
expect(wrapper.findAll('.waveform-chart__axis-endpoint')).toHaveLength(2)
|
||||
expect(wrapper.get('.waveform-chart__axis-exponent--x').text()).not.toBe('')
|
||||
expect(wrapper.get('.waveform-chart__axis-exponent--y').text()).not.toBe('')
|
||||
expect(wrapper.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
|
||||
expect(yAxes.some((axis) => axis.text().includes('E+04 '))).toBe(true)
|
||||
expect(wrapper.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-chart__grid').exists()).toBe(false)
|
||||
expect(wrapper.get('.waveform-chart__plot-frame').attributes()).toMatchObject({
|
||||
stroke: '#dc2626',
|
||||
|
||||
@@ -79,8 +79,8 @@ describe('WaveformChart', () => {
|
||||
|
||||
expect(wrapper.emitted('zoom-change')).toBeUndefined()
|
||||
expect(wrapper.emitted('zoom-end')).toBeUndefined()
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
|
||||
})
|
||||
|
||||
it('ignores independent viewport dragging', async () => {
|
||||
@@ -333,14 +333,14 @@ describe('WaveformChart', () => {
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe('0.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe('0')
|
||||
|
||||
overlay.element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('zoom-reset')).toHaveLength(1)
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
|
||||
})
|
||||
|
||||
it('exposes resetViewport for independent tracks', async () => {
|
||||
@@ -364,13 +364,13 @@ describe('WaveformChart', () => {
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).not.toBe('0.00')
|
||||
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).not.toBe('0')
|
||||
|
||||
const chart = wrapper.vm as unknown as { resetViewport: () => void }
|
||||
chart.resetViewport()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0.00')
|
||||
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1.00')
|
||||
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0')
|
||||
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1000')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,15 +49,15 @@ describe('WaveformChart wheel zoom out', () => {
|
||||
end: wrapper.get('.waveform-chart__axis-endpoint--end').text(),
|
||||
})
|
||||
|
||||
expect(endpoints()).toEqual({ start: '-5.00', end: '5.00' })
|
||||
expect(endpoints()).toEqual({ start: '-5000', end: '5000' })
|
||||
await dispatchWheel(-4000)
|
||||
expect(endpoints()).not.toEqual({ start: '-5.00', end: '5.00' })
|
||||
expect(endpoints()).not.toEqual({ start: '-5000', end: '5000' })
|
||||
|
||||
await wrapper.setProps({ data: createData(-0.125, 0.125) })
|
||||
await flushPromises()
|
||||
await dispatchWheel(4000)
|
||||
|
||||
expect(endpoints()).toEqual({ start: '-5.00', end: '5.00' })
|
||||
expect(endpoints()).toEqual({ start: '-5000', end: '5000' })
|
||||
})
|
||||
|
||||
it('emits one zoom-end payload after zooming a shared viewport out', async () => {
|
||||
|
||||
@@ -96,6 +96,76 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
|
||||
<Switch v-model:checked="model.yAxisLineVisible" size="small" aria-label="显示纵轴线" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="x-axis-label-controls">
|
||||
<label class="frame-style-control frame-style-control--switch">
|
||||
<span>自定义 Label</span>
|
||||
<Switch
|
||||
v-model:checked="model.xAxisLabelFormatterEnabled"
|
||||
size="small"
|
||||
aria-label="自定义 X 轴 Label"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="model.xAxisLabelFormatterEnabled" class="frame-style-control">
|
||||
<span>格式类型</span>
|
||||
<Select
|
||||
v-model:value="model.xAxisLabelFormat"
|
||||
:options="model.xAxisLabelFormatOptions"
|
||||
size="small"
|
||||
aria-label="X 轴 Label 格式类型"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'number'"
|
||||
class="frame-style-control"
|
||||
>
|
||||
<span>运算倍率</span>
|
||||
<InputNumber
|
||||
v-model:value="model.xAxisLabelMultiplier"
|
||||
:min="-1000000"
|
||||
:max="1000000"
|
||||
:step="0.1"
|
||||
size="small"
|
||||
aria-label="X 轴 Label 运算倍率"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'number'"
|
||||
class="frame-style-control"
|
||||
>
|
||||
<span>小数位数</span>
|
||||
<InputNumber
|
||||
v-model:value="model.xAxisLabelFractionDigits"
|
||||
:min="0"
|
||||
:max="12"
|
||||
:step="1"
|
||||
size="small"
|
||||
aria-label="X 轴 Label 小数位数"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'datetime'"
|
||||
class="frame-style-control"
|
||||
>
|
||||
<span>时区</span>
|
||||
<Select
|
||||
v-model:value="model.xAxisLabelTimeZone"
|
||||
:options="model.xAxisLabelTimeZoneOptions"
|
||||
size="small"
|
||||
aria-label="X 轴 Label 时区"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'datetime'"
|
||||
class="frame-style-control frame-style-control--switch"
|
||||
>
|
||||
<span>显示毫秒</span>
|
||||
<Switch
|
||||
v-model:checked="model.xAxisLabelShowMilliseconds"
|
||||
size="small"
|
||||
aria-label="X 轴 Label 显示毫秒"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="control-section">
|
||||
<h2>图框样式</h2>
|
||||
|
||||
@@ -14,6 +14,7 @@ import type {
|
||||
WaveformZeroLineOptions,
|
||||
WaveformZoomEndPayload,
|
||||
} from '../components'
|
||||
import type { DemoXAxisLabelFormat, DemoXAxisLabelTimeZone } from './useDemoXAxisLabelControls'
|
||||
|
||||
interface SelectOption<T> {
|
||||
label: string
|
||||
@@ -44,6 +45,14 @@ export interface DemoControlPanelModel {
|
||||
verticalGridColor: string
|
||||
xAxisLineVisible: boolean
|
||||
yAxisLineVisible: boolean
|
||||
xAxisLabelFormatterEnabled: boolean
|
||||
xAxisLabelFormat: DemoXAxisLabelFormat
|
||||
xAxisLabelFormatOptions: Array<SelectOption<DemoXAxisLabelFormat>>
|
||||
xAxisLabelMultiplier: number
|
||||
xAxisLabelFractionDigits: number
|
||||
xAxisLabelTimeZone: DemoXAxisLabelTimeZone
|
||||
xAxisLabelTimeZoneOptions: Array<SelectOption<DemoXAxisLabelTimeZone>>
|
||||
xAxisLabelShowMilliseconds: boolean
|
||||
frameBorderColor: string
|
||||
frameBackgroundColor: string
|
||||
frameBorderWidth: number
|
||||
|
||||
77
src/demo/useDemoXAxisLabelControls.ts
Normal file
77
src/demo/useDemoXAxisLabelControls.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { WaveformXAxisLabelFormatter } from '../types'
|
||||
|
||||
export type DemoXAxisLabelFormat = 'number' | 'datetime'
|
||||
export type DemoXAxisLabelTimeZone = 'local' | 'Asia/Shanghai' | 'UTC'
|
||||
|
||||
const formatOptions = [
|
||||
{ label: '数值', value: 'number' as const },
|
||||
{ label: '固定时间', value: 'datetime' as const },
|
||||
]
|
||||
const timeZoneOptions = [
|
||||
{ label: '中国标准时间', value: 'Asia/Shanghai' as const },
|
||||
{ label: '本地时区', value: 'local' as const },
|
||||
{ label: 'UTC', value: 'UTC' as const },
|
||||
]
|
||||
|
||||
function pad(value: number, length = 2): string {
|
||||
return String(value).padStart(length, '0')
|
||||
}
|
||||
|
||||
export function formatDemoTimestamp(
|
||||
value: number,
|
||||
timeZone: DemoXAxisLabelTimeZone,
|
||||
showMilliseconds: boolean,
|
||||
): string {
|
||||
const offset = timeZone === 'Asia/Shanghai' ? 8 * 60 * 60 * 1000 : 0
|
||||
const date = new Date(value + offset)
|
||||
if (!Number.isFinite(date.getTime())) return String(value)
|
||||
|
||||
const useUtcFields = timeZone !== 'local'
|
||||
const year = useUtcFields ? date.getUTCFullYear() : date.getFullYear()
|
||||
const month = (useUtcFields ? date.getUTCMonth() : date.getMonth()) + 1
|
||||
const day = useUtcFields ? date.getUTCDate() : date.getDate()
|
||||
const hours = useUtcFields ? date.getUTCHours() : date.getHours()
|
||||
const minutes = useUtcFields ? date.getUTCMinutes() : date.getMinutes()
|
||||
const seconds = useUtcFields ? date.getUTCSeconds() : date.getSeconds()
|
||||
const milliseconds = useUtcFields ? date.getUTCMilliseconds() : date.getMilliseconds()
|
||||
const suffix = showMilliseconds ? `.${pad(milliseconds, 3)}` : ''
|
||||
return `${year}-${pad(month)}-${pad(day)} ${pad(hours)}:${pad(minutes)}:${pad(seconds)}${suffix}`
|
||||
}
|
||||
|
||||
export function useDemoXAxisLabelControls() {
|
||||
const xAxisLabelFormatterEnabled = ref(false)
|
||||
const xAxisLabelFormat = ref<DemoXAxisLabelFormat>('number')
|
||||
const xAxisLabelMultiplier = ref(1)
|
||||
const xAxisLabelFractionDigits = ref(3)
|
||||
const xAxisLabelTimeZone = ref<DemoXAxisLabelTimeZone>('Asia/Shanghai')
|
||||
const xAxisLabelShowMilliseconds = ref(false)
|
||||
const xAxisLabelFormatter = computed<WaveformXAxisLabelFormatter | undefined>(() => {
|
||||
if (!xAxisLabelFormatterEnabled.value) return undefined
|
||||
if (xAxisLabelFormat.value === 'datetime') {
|
||||
return (value) =>
|
||||
formatDemoTimestamp(value, xAxisLabelTimeZone.value, xAxisLabelShowMilliseconds.value)
|
||||
}
|
||||
|
||||
const multiplier = Number.isFinite(xAxisLabelMultiplier.value) ? xAxisLabelMultiplier.value : 1
|
||||
const fractionDigits = Number.isFinite(xAxisLabelFractionDigits.value)
|
||||
? Math.min(12, Math.max(0, Math.trunc(xAxisLabelFractionDigits.value)))
|
||||
: 0
|
||||
return (value) => (value * multiplier).toFixed(fractionDigits)
|
||||
})
|
||||
|
||||
return {
|
||||
controlModel: {
|
||||
xAxisLabelFormatterEnabled,
|
||||
xAxisLabelFormat,
|
||||
xAxisLabelFormatOptions: formatOptions,
|
||||
xAxisLabelMultiplier,
|
||||
xAxisLabelFractionDigits,
|
||||
xAxisLabelTimeZone,
|
||||
xAxisLabelTimeZoneOptions: timeZoneOptions,
|
||||
xAxisLabelShowMilliseconds,
|
||||
},
|
||||
xAxisLabelFormatter,
|
||||
}
|
||||
}
|
||||
86
src/demo/xAxisLabelControls.test.ts
Normal file
86
src/demo/xAxisLabelControls.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { InputNumber, Select } from 'ant-design-vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import App from '../App.vue'
|
||||
import { WaveformChart } from '../components'
|
||||
import { formatDemoTimestamp } from './useDemoXAxisLabelControls'
|
||||
|
||||
describe('X-axis label demo controls', () => {
|
||||
it('formats timestamps in fixed UTC and China-standard-time formats', () => {
|
||||
expect(formatDemoTimestamp(0, 'UTC', false)).toBe('1970-01-01 00:00:00')
|
||||
expect(formatDemoTimestamp(123, 'UTC', true)).toBe('1970-01-01 00:00:00.123')
|
||||
expect(formatDemoTimestamp(0, 'Asia/Shanghai', false)).toBe('1970-01-01 08:00:00')
|
||||
expect(formatDemoTimestamp(Number.NaN, 'UTC', false)).toBe('NaN')
|
||||
})
|
||||
|
||||
it('configures numeric and timestamp labels from the sidebar', async () => {
|
||||
const wrapper = mount(App)
|
||||
await flushPromises()
|
||||
const chart = wrapper.getComponent(WaveformChart)
|
||||
|
||||
expect(chart.props('axes')).toEqual({
|
||||
x: { lineVisible: false },
|
||||
y: { lineVisible: false },
|
||||
})
|
||||
expect(wrapper.find('[aria-label="自定义 X 轴 Label"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="X 轴 Label 小数位数"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="X 轴 Label 格式类型"]').exists()).toBe(false)
|
||||
|
||||
await wrapper.get('[aria-label="自定义 X 轴 Label"]').trigger('click')
|
||||
await flushPromises()
|
||||
const inputNumbers = wrapper.findAllComponents(InputNumber)
|
||||
const multiplierInput = inputNumbers.find((input) =>
|
||||
input.find('[aria-label="X 轴 Label 运算倍率"]').exists(),
|
||||
)
|
||||
const fractionDigitsInput = inputNumbers.find((input) =>
|
||||
input.find('[aria-label="X 轴 Label 小数位数"]').exists(),
|
||||
)
|
||||
expect(multiplierInput).toBeDefined()
|
||||
expect(fractionDigitsInput).toBeDefined()
|
||||
expect(multiplierInput?.props('value')).toBe(1)
|
||||
expect(fractionDigitsInput?.props('value')).toBe(3)
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toMatch(/\.000$/)
|
||||
|
||||
multiplierInput?.vm.$emit('update:value', 2)
|
||||
fractionDigitsInput?.vm.$emit('update:value', 2)
|
||||
await flushPromises()
|
||||
const formatter = chart.props('axes')?.x?.labelFormatter
|
||||
expect(formatter).toBeTypeOf('function')
|
||||
expect(formatter?.(1.234, {} as never)).toBe('2.47')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toMatch(/\.00$/)
|
||||
|
||||
const formatSelect = wrapper
|
||||
.findAllComponents(Select)
|
||||
.find((select) => select.find('[aria-label="X 轴 Label 格式类型"]').exists())
|
||||
expect(formatSelect?.props('value')).toBe('number')
|
||||
formatSelect?.vm.$emit('update:value', 'datetime')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="X 轴 Label 小数位数"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="X 轴 Label 时区"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[aria-label="X 轴 Label 显示毫秒"]').exists()).toBe(true)
|
||||
expect(chart.props('axes')?.x?.labelFormatter?.(0, {} as never)).toBe('1970-01-01 08:00:00')
|
||||
|
||||
await wrapper.get('[aria-label="X 轴 Label 显示毫秒"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(chart.props('axes')?.x?.labelFormatter?.(123, {} as never)).toBe(
|
||||
'1970-01-01 08:00:00.123',
|
||||
)
|
||||
const timeZoneSelect = wrapper
|
||||
.findAllComponents(Select)
|
||||
.find((select) => select.find('[aria-label="X 轴 Label 时区"]').exists())
|
||||
timeZoneSelect?.vm.$emit('update:value', 'UTC')
|
||||
await flushPromises()
|
||||
expect(chart.props('axes')?.x?.labelFormatter?.(123, {} as never)).toBe(
|
||||
'1970-01-01 00:00:00.123',
|
||||
)
|
||||
|
||||
await wrapper.get('[aria-label="自定义 X 轴 Label"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(chart.props('axes')?.x?.labelFormatter).toBeUndefined()
|
||||
expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -23,6 +23,9 @@ export type {
|
||||
WaveformLegendOrientation,
|
||||
WaveformLegendOptions,
|
||||
WaveformFrameStyle,
|
||||
WaveformXAxisLabelKind,
|
||||
WaveformXAxisLabelFormatterContext,
|
||||
WaveformXAxisLabelFormatter,
|
||||
WaveformAxesOptions,
|
||||
WaveformZeroLineOptions,
|
||||
// 数据类型
|
||||
|
||||
@@ -133,6 +133,14 @@ body {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.x-axis-label-controls {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #eaecf0;
|
||||
}
|
||||
|
||||
.grid-line-color-picker,
|
||||
.grid-line-color-picker .vc-color-wrap {
|
||||
width: 48px;
|
||||
|
||||
@@ -121,10 +121,31 @@ export interface WaveformFrameStyle {
|
||||
backgroundColor?: string
|
||||
}
|
||||
|
||||
export type WaveformXAxisLabelKind = 'tick' | 'start' | 'end'
|
||||
|
||||
/** Context passed to custom X-axis label formatters. */
|
||||
export interface WaveformXAxisLabelFormatterContext {
|
||||
kind: WaveformXAxisLabelKind
|
||||
/** X coordinate in the source data, before time-unit conversion. */
|
||||
rawValue: number
|
||||
timeUnit: 's' | 'ms'
|
||||
/** Current visible X domain in source coordinates. */
|
||||
domain: [number, number]
|
||||
/** Current visible X domain converted to the selected display unit. */
|
||||
displayDomain: [number, number]
|
||||
}
|
||||
|
||||
export type WaveformXAxisLabelFormatter = (
|
||||
value: number,
|
||||
context: WaveformXAxisLabelFormatterContext,
|
||||
) => string
|
||||
|
||||
/** Controls axis baseline visibility while preserving tick marks and axis text. */
|
||||
export interface WaveformAxesOptions {
|
||||
x?: {
|
||||
lineVisible?: boolean
|
||||
/** Formats display-unit X values for ticks and visible-range endpoints. */
|
||||
labelFormatter?: WaveformXAxisLabelFormatter
|
||||
}
|
||||
y?: {
|
||||
lineVisible?: boolean
|
||||
|
||||
@@ -18,6 +18,9 @@ export type {
|
||||
WaveformLegendOrientation,
|
||||
WaveformLegendOptions,
|
||||
WaveformFrameStyle,
|
||||
WaveformXAxisLabelKind,
|
||||
WaveformXAxisLabelFormatterContext,
|
||||
WaveformXAxisLabelFormatter,
|
||||
WaveformAxesOptions,
|
||||
WaveformZeroLineOptions,
|
||||
} from './chart'
|
||||
|
||||
@@ -1,71 +1,112 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { WaveformXAxisLabelFormatter, WaveformXAxisLabelFormatterContext } from '../types'
|
||||
import {
|
||||
formatAnnotationTime,
|
||||
formatAxisTime,
|
||||
formatAxisTimeExponent,
|
||||
formatEndpointTime,
|
||||
formatPlainNumber,
|
||||
formatScientificAxisExponent,
|
||||
formatScientificAxisLabel,
|
||||
formatTooltipNumber,
|
||||
formatTooltipTime,
|
||||
formatXAxisLabel,
|
||||
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 complete plain values in the 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.4')
|
||||
expect(formatEndpointTime(1, domain, 'ms')).toBe('1000')
|
||||
expect(formatAxisTime(0.5, 's', domain)).toBe('0.5')
|
||||
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.000001')
|
||||
expect(formatAxisTime(-0.000001, 's', tinyDomain)).toBe('-0.000001')
|
||||
expect(formatAxisTime(-0, 's')).toBe('0')
|
||||
expect(formatAxisTime(1e21, 's')).toBe('1000000000000000000000')
|
||||
expect(formatAxisTime(Number.POSITIVE_INFINITY, 's')).toBe('Infinity')
|
||||
})
|
||||
|
||||
it('passes display values and complete source context to X-axis label formatters', () => {
|
||||
const domain: [number, number] = [0.125, 1.875]
|
||||
const formatter: WaveformXAxisLabelFormatter = (value, context) =>
|
||||
`${context.kind}:${value / 10}`
|
||||
|
||||
expect(formatXAxisLabel(0.5, domain, 'ms', 'tick', formatter)).toBe('tick:50')
|
||||
expect(formatXAxisLabel(domain[0], domain, 'ms', 'start', formatter)).toBe('start:12.5')
|
||||
expect(formatXAxisLabel(domain[1], domain, 'ms', 'end', formatter)).toBe('end:187.5')
|
||||
|
||||
let receivedContext: WaveformXAxisLabelFormatterContext | undefined
|
||||
formatXAxisLabel(0.5, domain, 'ms', 'tick', (value, context) => {
|
||||
receivedContext = context
|
||||
return String(value)
|
||||
})
|
||||
expect(receivedContext).toEqual({
|
||||
kind: 'tick',
|
||||
rawValue: 0.5,
|
||||
timeUnit: 'ms',
|
||||
domain: [0.125, 1.875],
|
||||
displayDomain: [125, 1875],
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
formatXAxisLabel(0.5, domain, 's', 'tick', () => {
|
||||
throw new Error('formatter failed')
|
||||
}),
|
||||
).toThrow('formatter failed')
|
||||
})
|
||||
|
||||
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')
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import type { WaveformXAxisLabelFormatter, WaveformXAxisLabelKind } from '../types'
|
||||
|
||||
/**
|
||||
* 时间单位类型
|
||||
*/
|
||||
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,
|
||||
})
|
||||
@@ -38,9 +42,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 +54,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 +68,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. */
|
||||
@@ -119,6 +134,26 @@ export function displayTime(value: number, timeUnit: TimeUnit): number {
|
||||
return timeUnit === 'ms' ? value * 1000 : value
|
||||
}
|
||||
|
||||
/** Format an X-axis value without changing its source coordinate. */
|
||||
export function formatXAxisLabel(
|
||||
rawValue: number,
|
||||
domain: [number, number],
|
||||
timeUnit: TimeUnit,
|
||||
kind: WaveformXAxisLabelKind,
|
||||
formatter?: WaveformXAxisLabelFormatter,
|
||||
): string {
|
||||
const value = displayTime(rawValue, timeUnit)
|
||||
if (!formatter) return formatPlainNumber(value)
|
||||
|
||||
return formatter(value, {
|
||||
kind,
|
||||
rawValue,
|
||||
timeUnit,
|
||||
domain: [domain[0], domain[1]],
|
||||
displayDomain: [displayTime(domain[0], timeUnit), displayTime(domain[1], timeUnit)],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算端点标签的小数位数
|
||||
* @param domain 数据域 [最小值, 最大值]
|
||||
@@ -134,54 +169,33 @@ 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 formatXAxisLabel(value, _domain, timeUnit, 'end')
|
||||
}
|
||||
|
||||
/**
|
||||
* 按完整 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)
|
||||
return formatXAxisLabel(value, _domain ?? [value, value], timeUnit, 'tick')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,11 +205,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. */
|
||||
|
||||
@@ -11,7 +11,7 @@ export {
|
||||
endpointFractionDigits,
|
||||
formatEndpointTime,
|
||||
formatAxisTime,
|
||||
formatAxisTimeExponent,
|
||||
formatXAxisLabel,
|
||||
formatTooltipTime,
|
||||
formatAnnotationTime,
|
||||
formatPlainNumber,
|
||||
|
||||
Reference in New Issue
Block a user