3 Commits

Author SHA1 Message Date
李启源
9eb1f0f137 feat(chart): support configurable plot margins
All checks were successful
Package component / package (push) Successful in 7m2s
2026-08-05 15:16:52 +08:00
李启源
fbf10fdb88 feat(chart): support configurable x-axis labels
All checks were successful
Package component / package (push) Successful in 4m35s
2026-08-05 10:58:40 +08:00
李启源
c5aa3c662a fix(chart): refine axis number formatting
All checks were successful
Package component / package (push) Successful in 4m2s
2026-08-04 11:28:42 +08:00
42 changed files with 879 additions and 317 deletions

View File

@@ -97,7 +97,7 @@ const data = ref<WaveformData>({
### Props ### Props
| Prop | 类型 | 默认值 | 说明 | | Prop | 类型 | 默认值 | 说明 |
| --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | --------------------------------- | | --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | ---------------------------------- |
| `data` | `WaveformData` | 必填 | 波形数据 | | `data` | `WaveformData` | 必填 | 波形数据 |
| `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 | | `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 |
| `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 | | `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 |
@@ -114,7 +114,7 @@ const data = ref<WaveformData>({
| `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 | | `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 |
| `yDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置固定范围 | | `yDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置固定范围 |
| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 | | `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 |
| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐 | | `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐与 X 轴 label 格式化 |
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 | | `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 | | `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
| `frameNumber` | `string \| number` | 未设置 | 图框水印内容 | | `frameNumber` | `string \| number` | 未设置 | 图框水印内容 |
@@ -129,8 +129,8 @@ const data = ref<WaveformData>({
所有公开类型均可从包入口导入,例如 `WaveformData``WaveformSeries` 所有公开类型均可从包入口导入,例如 `WaveformData``WaveformSeries`
`WaveformAnnotation``WaveformLineStyle``WaveformRenderingOptions` `WaveformAnnotation``WaveformLineStyle``WaveformRenderingOptions`
`WaveformAxesOptions``WaveformZeroLineOptions``WaveformGridOptions` `WaveformAxesOptions``WaveformXAxisLabelFormatter``WaveformZeroLineOptions`
`WaveformGridTrackLines` `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`,默认使用缩放模式。右键绘图区可直接打开 `interactionMode` 可选 `zoom``annotation`,默认使用缩放模式。右键绘图区可直接打开
标注编辑器,无需切换交互模式。`zoomable``pannable``showTooltip` 可分别控制缩放、 标注编辑器,无需切换交互模式。`zoomable``pannable``showTooltip` 可分别控制缩放、
空格拖拽平移和 tooltip平移默认关闭。 空格拖拽平移和 tooltip平移默认关闭。
@@ -603,7 +627,7 @@ async function importAnnotationFile(file: File) {
字段无效时会抛出 `TypeError`,不会返回部分结果。导入包含未知 `seriesId` 的标注是允许的, 字段无效时会抛出 `TypeError`,不会返回部分结果。导入包含未知 `seriesId` 的标注是允许的,
对应曲线加载后会恢复显示。文件选择、错误提示和下载由业务层实现。 对应曲线加载后会恢复显示。文件选择、错误提示和下载由业务层实现。
XY 轴会根据各自完整显示域选择格式:最大绝对值在 `[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 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。
标注框默认布局在采样点正上方,只做绘图区边界裁剪;文本框通过连接箭头指向标注位置,多个标注重叠时可通过拖动手动避让。 标注框默认布局在采样点正上方,只做绘图区边界裁剪;文本框通过连接箭头指向标注位置,多个标注重叠时可通过拖动手动避让。
## 事件 ## 事件

View File

@@ -1,6 +1,6 @@
{ {
"name": "waveform-analysis", "name": "waveform-analysis",
"version": "0.1.27", "version": "0.1.30",
"main": "./dist/index.cjs", "main": "./dist/index.cjs",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/types/index.d.ts", "types": "./dist/types/index.d.ts",

View File

@@ -14,6 +14,7 @@ import {
type WaveformLegendOrientation, type WaveformLegendOrientation,
type WaveformLegendPosition, type WaveformLegendPosition,
type WaveformOverlayMode, type WaveformOverlayMode,
type WaveformPlotMargin,
type WaveformTitleOptions, type WaveformTitleOptions,
type WaveformZoomEndPayload, type WaveformZoomEndPayload,
type WaveformZeroLineOptions, type WaveformZeroLineOptions,
@@ -23,6 +24,7 @@ import { createSimulatedWaveformData } from './data/simulatedWaveforms'
import DemoChartHost from './demo/DemoChartHost.vue' import DemoChartHost from './demo/DemoChartHost.vue'
import DemoControlPanel from './demo/DemoControlPanel.vue' import DemoControlPanel from './demo/DemoControlPanel.vue'
import type { DemoChartModel, DemoControlPanelModel } from './demo/types' import type { DemoChartModel, DemoControlPanelModel } from './demo/types'
import { useDemoXAxisLabelControls } from './demo/useDemoXAxisLabelControls'
const fullChartData = createSimulatedWaveformData() const fullChartData = createSimulatedWaveformData()
const displayMode = ref<WaveformDisplayMode>('independent') const displayMode = ref<WaveformDisplayMode>('independent')
@@ -40,11 +42,14 @@ const verticalGridVisible = ref(true)
const verticalGridColor = ref('#dfe5ef') const verticalGridColor = ref('#dfe5ef')
const xAxisLineVisible = ref(false) const xAxisLineVisible = ref(false)
const yAxisLineVisible = ref(false) const yAxisLineVisible = ref(false)
const { controlModel: xAxisLabelControlModel, xAxisLabelFormatter } = useDemoXAxisLabelControls()
const annotations = ref<WaveformAnnotation[]>([]) const annotations = ref<WaveformAnnotation[]>([])
const annotationsVisible = ref(true) const annotationsVisible = ref(true)
const cleanView = ref(false) const cleanView = ref(false)
const presentationMode = ref(false) const presentationMode = ref(false)
const showTooltip = ref(true) const showTooltip = ref(true)
const plotMarginTop = ref(18)
const plotMarginBottom = ref(52)
const zeroLineVisible = ref(false) const zeroLineVisible = ref(false)
const zeroLineColor = ref('#98a2b3') const zeroLineColor = ref('#98a2b3')
const zeroLineWidth = ref(1) const zeroLineWidth = ref(1)
@@ -112,7 +117,10 @@ const frameStyle = computed<WaveformFrameStyle>(() => ({
backgroundColor: frameBackgroundColor.value, backgroundColor: frameBackgroundColor.value,
})) }))
const axes = computed<WaveformAxesOptions>(() => ({ const axes = computed<WaveformAxesOptions>(() => ({
x: { lineVisible: xAxisLineVisible.value }, x: {
lineVisible: xAxisLineVisible.value,
...(xAxisLabelFormatter.value ? { labelFormatter: xAxisLabelFormatter.value } : {}),
},
y: { lineVisible: yAxisLineVisible.value }, y: { lineVisible: yAxisLineVisible.value },
})) }))
const zeroLine = computed<WaveformZeroLineOptions>(() => ({ const zeroLine = computed<WaveformZeroLineOptions>(() => ({
@@ -121,6 +129,10 @@ const zeroLine = computed<WaveformZeroLineOptions>(() => ({
width: zeroLineWidth.value, width: zeroLineWidth.value,
dash: zeroLineDash.value, dash: zeroLineDash.value,
})) }))
const plotMargin = computed<WaveformPlotMargin>(() => ({
top: plotMarginTop.value,
bottom: plotMarginBottom.value,
}))
const initialXValues = normalizeWaveformSeries(fullChartData).flatMap((series) => const initialXValues = normalizeWaveformSeries(fullChartData).flatMap((series) =>
series.points.map((point) => point.x), series.points.map((point) => point.x),
@@ -289,6 +301,8 @@ const controlPanelModel = reactive({
displayMode, displayMode,
overlayMode, overlayMode,
showTooltip, showTooltip,
plotMarginTop,
plotMarginBottom,
cleanView, cleanView,
presentationMode, presentationMode,
selectedSeriesId, selectedSeriesId,
@@ -308,6 +322,7 @@ const controlPanelModel = reactive({
verticalGridColor, verticalGridColor,
xAxisLineVisible, xAxisLineVisible,
yAxisLineVisible, yAxisLineVisible,
...xAxisLabelControlModel,
frameBorderColor, frameBorderColor,
frameBackgroundColor, frameBackgroundColor,
frameBorderWidth, frameBorderWidth,
@@ -354,6 +369,7 @@ const chartModel = reactive({
cleanView, cleanView,
presentationMode, presentationMode,
showTooltip, showTooltip,
plotMargin,
zeroLine, zeroLine,
frameWatermarkVisible, frameWatermarkVisible,
annotations, annotations,

View File

@@ -23,6 +23,7 @@ const props = withDefaults(defineProps<WaveformChartProps>(), {
interactionMode: undefined, interactionMode: undefined,
grid: () => ({ rowCount: 2, columnCount: 1, showPagination: true }), grid: () => ({ rowCount: 2, columnCount: 1, showPagination: true }),
rendering: () => ({}), rendering: () => ({}),
plotMargin: () => ({}),
legend: () => ({ position: 'top-right', orientation: 'auto' }), legend: () => ({ position: 'top-right', orientation: 'auto' }),
defaultHiddenSeriesIds: () => [], defaultHiddenSeriesIds: () => [],
cleanView: false, cleanView: false,

View File

@@ -19,6 +19,7 @@ const {
overlayMode, overlayMode,
resolvedChartLeftMargin, resolvedChartLeftMargin,
titleAreaHeight, titleAreaHeight,
resolvedPlotMargin,
pointerInsideChart, pointerInsideChart,
handleNativeContextMenu, handleNativeContextMenu,
titleAreaReserved, titleAreaReserved,
@@ -118,6 +119,8 @@ const {
:data-presentation-mode="isPresentationMode" :data-presentation-mode="isPresentationMode"
:data-overlay-mode="overlayMode" :data-overlay-mode="overlayMode"
:data-chart-left-margin="resolvedChartLeftMargin" :data-chart-left-margin="resolvedChartLeftMargin"
:data-plot-margin-top="resolvedPlotMargin.top"
:data-plot-margin-bottom="resolvedPlotMargin.bottom"
:data-title-area-height="titleAreaHeight" :data-title-area-height="titleAreaHeight"
@pointerenter="pointerInsideChart = true" @pointerenter="pointerInsideChart = true"
@pointerleave="pointerInsideChart = false" @pointerleave="pointerInsideChart = false"
@@ -287,17 +290,17 @@ const {
/> />
</g> </g>
</g> </g>
</g>
<text <text
v-if="resolvedXLabel && !isCleanView" v-if="resolvedXLabel && !isCleanView"
class="waveform-chart__label waveform-chart__x-label" class="waveform-chart__label waveform-chart__x-label"
:x="innerWidth / 2" :x="resolvedChartLeftMargin + innerWidth / 2"
:y="xAxisTitleY" :y="xAxisTitleY"
text-anchor="middle" text-anchor="middle"
> >
{{ resolvedXLabel }} {{ resolvedXLabel }}
</text> </text>
</g>
<text <text
v-if="hasChartArea && !hasWaveformData" v-if="hasChartArea && !hasWaveformData"

View File

@@ -7,6 +7,9 @@
/** 图表边距 */ /** 图表边距 */
export const margin = { top: 18, right: 24, bottom: 52, left: 64 } export const margin = { top: 18, right: 24, bottom: 52, left: 64 }
/** Distance from the drawing SVG bottom edge to the X-axis title baseline. */
export const X_AXIS_TITLE_BOTTOM_OFFSET = 12
/** /**
* 图表最小高度(像素) * 图表最小高度(像素)
*/ */

View File

@@ -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({ const layout = buildTrackLayouts({
cells: [ cells: [
{ {
@@ -206,7 +206,7 @@ describe('multi-value Y-axis grouping', () => {
plotHeight: 300, plotHeight: 300,
cellHeight: 330, cellHeight: 330,
xAxisBand: 30, 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: {} }, grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
@@ -222,14 +222,13 @@ describe('multi-value Y-axis grouping', () => {
})[0] })[0]
expect( expect(
layout?.yAxes.map(({ side, x, exponentX, exponentLabel }) => ({ layout?.yAxes.map(({ side, x, labelX }) => ({
side, side,
offset: Math.abs(exponentX - x), labelOffset: Math.abs(labelX - x),
exponentLabel,
})), })),
).toEqual([ ).toEqual([
{ side: 'left', offset: 43, exponentLabel: 'E+02' }, { side: 'left', labelOffset: 67 },
{ side: 'right', offset: 43, exponentLabel: 'E+02' }, { side: 'right', labelOffset: 67 },
]) ])
}) })
@@ -237,7 +236,7 @@ describe('multi-value Y-axis grouping', () => {
const [group] = buildYAxisSeriesGroups(track([series('long', -1e120, 1e120)]), 'multi-axis') const [group] = buildYAxisSeriesGroups(track([series('long', -1e120, 1e120)]), 'multi-axis')
expect(group).toBeDefined() expect(group).toBeDefined()
expect(measureYAxisGroupClearance(group!)).toBe(119) expect(measureYAxisGroupClearance(group!)).toBe(90)
}) })
}) })

View File

@@ -1,9 +1,9 @@
import { scaleLinear } from 'd3' import { scaleLinear } from 'd3'
import type { WaveformOverlayMode } from '../../types' import type { WaveformOverlayMode } from '../../types'
import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../../utils' import { formatScientificAxisLabel, paddedDomain } from '../../utils'
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types' 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 { import {
mergeYDomains, mergeYDomains,
resolveSeriesFixedYDomain, resolveSeriesFixedYDomain,
@@ -12,7 +12,7 @@ import {
} from './yDomain' } 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_CHARACTER_WIDTH = 7
const Y_AXIS_TICK_PADDING = 7 const Y_AXIS_TICK_PADDING = 7
@@ -102,36 +102,27 @@ export function resolveYAxisSeriesGroups(
export function axisTextMetrics( export function axisTextMetrics(
domain: [number, number], domain: [number, number],
nice = true, nice = true,
): { tickValues?: number[],
exponentLabel: string | null ): { tickTextWidth: number } {
exponentWidth: number
tickTextWidth: number
} {
const scale = scaleLinear(domain, [1, 0]) const scale = scaleLinear(domain, [1, 0])
if (nice) scale.nice() if (nice) scale.nice()
const [axisMin, axisMax] = scale.domain() 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( const maximumTickCharacters = Math.max(
1, 1,
...values.map((value) => formatScientificAxisLabel(value, { axisMin, axisMax }).length), ...values.map(
(value) => formatScientificAxisLabel(value, { axisMin, axisMax, topTickValue }).length,
),
) )
const exponentLabel = formatScientificAxisExponent(axisMin, axisMax)
return { return {
exponentLabel,
exponentWidth: exponentLabel ? exponentLabel.length * Y_AXIS_CHARACTER_WIDTH : 0,
tickTextWidth: maximumTickCharacters * Y_AXIS_CHARACTER_WIDTH, 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 { export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
return ( return (
axisTextMetrics(group.domain, !group.fixed).tickTextWidth + axisTextMetrics(group.domain, !group.fixed).tickTextWidth +
axisExponentClearance(group.domain, !group.fixed) +
Y_AXIS_TICK_PADDING + Y_AXIS_TICK_PADDING +
Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH + Y_AXIS_LABEL_BAND_WIDTH +
@@ -142,7 +133,6 @@ export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number { function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number {
return ( return (
axisTextMetrics(group.domain, !group.fixed).tickTextWidth + axisTextMetrics(group.domain, !group.fixed).tickTextWidth +
axisExponentClearance(group.domain, !group.fixed) +
Y_AXIS_TICK_PADDING + Y_AXIS_TICK_PADDING +
Y_AXIS_OUTER_PADDING Y_AXIS_OUTER_PADDING
) )

View File

@@ -12,8 +12,13 @@ import {
selectSeriesRenderPoints, selectSeriesRenderPoints,
type ResolvedWaveformRenderingOptions, type ResolvedWaveformRenderingOptions,
} from '../../core/rendering' } from '../../core/rendering'
import type { WaveformDisplayMode, WaveformOverlayMode, WaveformPoint } from '../../types' import type {
import { buildMinorTicks, formatAxisTimeExponent, formatEndpointTime } from '../../utils' WaveformDisplayMode,
WaveformOverlayMode,
WaveformPoint,
WaveformXAxisLabelFormatter,
} from '../../types'
import { buildMinorTicks, formatXAxisLabel } from '../../utils'
import { import {
getBottomRowCellIndexes, getBottomRowCellIndexes,
type GridCellGeometry, type GridCellGeometry,
@@ -21,7 +26,6 @@ import {
} from './grid' } from './grid'
import { axisTextMetrics, resolveYAxisSeriesGroups } from './layout' import { axisTextMetrics, resolveYAxisSeriesGroups } from './layout'
import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types' import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
import { Y_AXIS_EXPONENT_GAP } from './constants'
import { import {
Y_AXIS_LABEL_BAND_WIDTH, Y_AXIS_LABEL_BAND_WIDTH,
Y_AXIS_LABEL_GAP, Y_AXIS_LABEL_GAP,
@@ -46,6 +50,7 @@ export interface BuildTrackLayoutsOptions {
fixedYDomains?: Record<string, [number, number]> fixedYDomains?: Record<string, [number, number]>
yDomains?: Record<string, [number, number]> yDomains?: Record<string, [number, number]>
timeUnit: 's' | 'ms' timeUnit: 's' | 'ms'
xAxisLabelFormatter?: WaveformXAxisLabelFormatter
rendering: ResolvedWaveformRenderingOptions rendering: ResolvedWaveformRenderingOptions
hideSecondaryLabels: boolean hideSecondaryLabels: boolean
yAxisLabelX: number yAxisLabelX: number
@@ -118,31 +123,16 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
const tickValues = Array.from( const tickValues = Array.from(
new Set([axisStart, ...visibleMajorTicks, ...(showAxisEnd ? [axisEnd] : [])]), new Set([axisStart, ...visibleMajorTicks, ...(showAxisEnd ? [axisEnd] : [])]),
) )
const { exponentLabel, exponentWidth, tickTextWidth } = axisTextMetrics( const { tickTextWidth } = axisTextMetrics(group.domain, !group.fixed, tickValues)
group.domain,
!group.fixed,
)
const exponentClearance = exponentLabel ? exponentWidth + Y_AXIS_EXPONENT_GAP : 0
const clearance = const clearance =
tickTextWidth + tickTextWidth +
Y_AXIS_TICK_PADDING + Y_AXIS_TICK_PADDING +
exponentClearance +
Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH + Y_AXIS_LABEL_BAND_WIDTH +
Y_AXIS_OUTER_PADDING Y_AXIS_OUTER_PADDING
const x = group.side === 'left' ? -sideOffsets.left : cell.width + sideOffsets.right 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 = const labelDistance =
tickTextWidth + tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_BAND_WIDTH / 2
Y_AXIS_TICK_PADDING +
exponentClearance +
exponentWidth +
Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH / 2
const labelX = x + (group.side === 'left' ? -labelDistance : labelDistance) const labelX = x + (group.side === 'left' ? -labelDistance : labelDistance)
sideOffsets[group.side] += clearance sideOffsets[group.side] += clearance
return { return {
@@ -150,8 +140,6 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
side: group.side, side: group.side,
x, x,
labelX, labelX,
exponentX,
exponentLabel,
scale, scale,
majorTicks, majorTicks,
minorTicks: buildMinorTicks(majorTicks), minorTicks: buildMinorTicks(majorTicks),
@@ -165,10 +153,21 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
const yAxisTickValues = yAxes[0]?.tickValues ?? [] const yAxisTickValues = yAxes[0]?.tickValues ?? []
const domain = xScale.domain() as [number, number] const domain = xScale.domain() as [number, number]
const endpointLabels = { const endpointLabels = {
start: formatEndpointTime(domain[0], domain, options.timeUnit), start: formatXAxisLabel(
end: formatEndpointTime(domain[1], domain, options.timeUnit), 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 leftClearance = endpointLabels.start.length * 7 + 10
const rightClearance = endpointLabels.end.length * 7 + 10 const rightClearance = endpointLabels.end.length * 7 + 10
const xAxisTickValues = xMajorTicks.filter((tick) => { const xAxisTickValues = xMajorTicks.filter((tick) => {
@@ -235,7 +234,6 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
yAxisTickValues, yAxisTickValues,
xAxisTickValues, xAxisTickValues,
endpointLabels, endpointLabels,
xAxisExponent,
path: seriesPaths[0]?.path ?? null, path: seriesPaths[0]?.path ?? null,
seriesPaths, seriesPaths,
gridLines: options.grid.trackLines[displayTrack.id] ?? { gridLines: options.grid.trackLines[displayTrack.id] ?? {

View File

@@ -51,8 +51,6 @@ export interface WaveformYAxisLayout {
side: 'left' | 'right' side: 'left' | 'right'
x: number x: number
labelX: number labelX: number
exponentX: number
exponentLabel: string | null
scale: ScaleLinear<number, number> scale: ScaleLinear<number, number>
majorTicks: number[] majorTicks: number[]
minorTicks: number[] minorTicks: number[]
@@ -109,7 +107,6 @@ export interface TrackLayout {
yAxisTickValues: number[] yAxisTickValues: number[]
xAxisTickValues: number[] xAxisTickValues: number[]
endpointLabels: { start: string; end: string } endpointLabels: { start: string; end: string }
xAxisExponent: string | null
path: string | null path: string | null
seriesPaths: TrackSeriesPath[] seriesPaths: TrackSeriesPath[]
showXAxis: boolean showXAxis: boolean

View File

@@ -2,7 +2,7 @@ import { scaleLinear, type ZoomTransform } from 'd3'
import { computed, type ComputedRef, type Ref, type ShallowRef } from 'vue' import { computed, type ComputedRef, type Ref, type ShallowRef } from 'vue'
import { resolveWaveformRenderingOptions } from '../../core' import { resolveWaveformRenderingOptions } from '../../core'
import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../../utils' import { paddedDomain } from '../../utils'
import { import {
layoutAnnotations, layoutAnnotations,
type AnnotationSeriesInfo, type AnnotationSeriesInfo,
@@ -24,13 +24,12 @@ import {
normalizeGridOptions, normalizeGridOptions,
paginateSeries, paginateSeries,
resolveGridCellGeometry, resolveGridCellGeometry,
X_AXIS_BAND,
} from './grid' } from './grid'
import { import {
buildTrackLayouts, buildTrackLayouts,
axisTextMetrics,
measureTrackYAxisClearance, measureTrackYAxisClearance,
resolveYAxisSeriesGroups, resolveYAxisSeriesGroups,
Y_AXIS_EXPONENT_GAP,
} from './layout' } from './layout'
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types' import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
import type { PreparedWaveformSeries } from './useWaveformData' import type { PreparedWaveformSeries } from './useWaveformData'
@@ -112,40 +111,18 @@ export function useWaveformLayout(context: LayoutContext) {
.flatMap((track) => .flatMap((track) =>
resolveYAxisSeriesGroups(track, props.overlayMode, props.yDomain, props.yDomains), resolveYAxisSeriesGroups(track, props.overlayMode, props.yDomain, props.yDomains),
) )
.map((group) => { .map((group) => axisTextMetrics(group.domain, !group.fixed).tickTextWidth)
const scale = scaleLinear(group.domain, [1, 0]) const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText)
if (!group.fixed) scale.nice() const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING
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
const labelCenterX = -( const labelCenterX = -(
Y_AXIS_TICK_PADDING + Y_AXIS_TICK_PADDING +
tickTextWidth + tickTextWidth +
exponentClearance +
Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH / 2 Y_AXIS_LABEL_BAND_WIDTH / 2
) )
const fullClearance = const fullClearance =
tickTextWidth + tickTextWidth +
Y_AXIS_TICK_PADDING + Y_AXIS_TICK_PADDING +
exponentClearance +
Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH + Y_AXIS_LABEL_BAND_WIDTH +
Y_AXIS_OUTER_PADDING Y_AXIS_OUTER_PADDING
@@ -307,6 +284,7 @@ export function useWaveformLayout(context: LayoutContext) {
) )
: sharedYDomains.value, : sharedYDomains.value,
timeUnit: props.timeUnit, timeUnit: props.timeUnit,
xAxisLabelFormatter: props.axes?.x?.labelFormatter,
rendering: renderingOptions.value, rendering: renderingOptions.value,
hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels, hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
yAxisLabelX: yAxisMetrics.value.labelCenterX, yAxisLabelX: yAxisMetrics.value.labelCenterX,
@@ -337,7 +315,6 @@ export function useWaveformLayout(context: LayoutContext) {
) )
: [], : [],
) )
const xAxisTitleY = computed(() => innerHeight.value + X_AXIS_BAND + 10)
const editorSeries = computed<AnnotationSeriesInfo | undefined>(() => { const editorSeries = computed<AnnotationSeriesInfo | undefined>(() => {
const seriesId = annotationInteraction.editorDraft.value?.annotation.seriesId const seriesId = annotationInteraction.editorDraft.value?.annotation.seriesId
const series = chartSeries.value.find((item) => item.id === seriesId) const series = chartSeries.value.find((item) => item.id === seriesId)
@@ -381,7 +358,6 @@ export function useWaveformLayout(context: LayoutContext) {
resolveSeriesYScale, resolveSeriesYScale,
annotationTrackLayouts, annotationTrackLayouts,
renderedAnnotations, renderedAnnotations,
xAxisTitleY,
editorSeries, editorSeries,
resolveFrameNumber, resolveFrameNumber,
} }

View File

@@ -7,6 +7,7 @@ import {
TITLE_CHAR_WIDTH_RATIO, TITLE_CHAR_WIDTH_RATIO,
TITLE_DEFAULT_FONT_SIZE, TITLE_DEFAULT_FONT_SIZE,
TITLE_LINE_HEIGHT, TITLE_LINE_HEIGHT,
X_AXIS_TITLE_BOTTOM_OFFSET,
ZERO_LINE_DEFAULTS, ZERO_LINE_DEFAULTS,
} from './constants' } from './constants'
import { calculateRotatedTitleLayout, TITLE_AREA_HORIZONTAL_PADDING } from './title' import { calculateRotatedTitleLayout, TITLE_AREA_HORIZONTAL_PADDING } from './title'
@@ -144,11 +145,23 @@ export function useWaveformPresentation(context: PresentationContext) {
const titleAreaHeight = computed(() => const titleAreaHeight = computed(() =>
titleAreaReserved.value ? titleLayout.value.areaHeight : 0, titleAreaReserved.value ? titleLayout.value.areaHeight : 0,
) )
const chartTopMargin = computed(() => margin.top) const resolvePlotMargin = (value: number | undefined, fallback: number) =>
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
const resolvedPlotMargin = computed(() => ({
top: resolvePlotMargin(props.plotMargin.top, margin.top),
bottom: resolvePlotMargin(props.plotMargin.bottom, margin.bottom),
}))
const chartTopMargin = computed(() => resolvedPlotMargin.value.top)
const drawingHeight = computed(() => const drawingHeight = computed(() =>
Math.max(0, chartHeight.value - titleAreaHeight.value - paginationBandHeight.value), Math.max(0, chartHeight.value - titleAreaHeight.value - paginationBandHeight.value),
) )
const innerHeight = computed(() => Math.max(0, drawingHeight.value - margin.top - margin.bottom)) const xAxisTitleY = computed(() => Math.max(0, drawingHeight.value - X_AXIS_TITLE_BOTTOM_OFFSET))
const innerHeight = computed(() =>
Math.max(
0,
drawingHeight.value - resolvedPlotMargin.value.top - resolvedPlotMargin.value.bottom,
),
)
const titleAreaStyle = computed<CSSProperties>(() => ({ const titleAreaStyle = computed<CSSProperties>(() => ({
height: `${titleAreaHeight.value}px`, height: `${titleAreaHeight.value}px`,
justifyContent: justifyContent:
@@ -193,8 +206,10 @@ export function useWaveformPresentation(context: PresentationContext) {
titleMeasureStyle, titleMeasureStyle,
titleLayout, titleLayout,
titleAreaHeight, titleAreaHeight,
resolvedPlotMargin,
chartTopMargin, chartTopMargin,
drawingHeight, drawingHeight,
xAxisTitleY,
innerHeight, innerHeight,
titleAreaStyle, titleAreaStyle,
titleVisualStyle, titleVisualStyle,

View File

@@ -7,6 +7,7 @@ import type {
WaveformInteractionMode, WaveformInteractionMode,
WaveformLegendOptions, WaveformLegendOptions,
WaveformOverlayMode, WaveformOverlayMode,
WaveformPlotMargin,
WaveformPoint, WaveformPoint,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformTitleOptions, WaveformTitleOptions,
@@ -42,6 +43,7 @@ export interface WaveformChartProps {
interactionMode?: WaveformInteractionMode interactionMode?: WaveformInteractionMode
grid?: WaveformGridOptions grid?: WaveformGridOptions
rendering?: WaveformRenderingOptions rendering?: WaveformRenderingOptions
plotMargin?: WaveformPlotMargin
title?: WaveformTitleOptions title?: WaveformTitleOptions
legend?: WaveformLegendOptions legend?: WaveformLegendOptions
hiddenSeriesIds?: string[] hiddenSeriesIds?: string[]
@@ -65,6 +67,7 @@ type DefaultedProp =
| 'annotationsVisible' | 'annotationsVisible'
| 'grid' | 'grid'
| 'rendering' | 'rendering'
| 'plotMargin'
| 'legend' | 'legend'
| 'defaultHiddenSeriesIds' | 'defaultHiddenSeriesIds'
| 'cleanView' | 'cleanView'

View File

@@ -13,6 +13,7 @@ export type {
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle, WaveformTitleTextStyle,
WaveformTitleOptions, WaveformTitleOptions,
WaveformLegendPosition, WaveformLegendPosition,

View File

@@ -11,6 +11,7 @@ export type {
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle, WaveformTitleTextStyle,
WaveformTitleOptions, WaveformTitleOptions,
WaveformLegendPosition, WaveformLegendPosition,

View File

@@ -91,6 +91,22 @@ describe('WaveformTooltip', () => {
expect(wrapper.get('.waveform-tooltip__value').text()).toBe('-1,405.4932 A') 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', () => { it('omits the error label when both resolved errors are zero', () => {
const point = { x: 1, y: 12 } const point = { x: 1, y: 12 }
const wrapper = mount(WaveformTooltip, { const wrapper = mount(WaveformTooltip, {

View File

@@ -3,7 +3,7 @@ import { axisBottom, axisLeft, axisRight, select } from 'd3'
import { nextTick, onMounted, ref, watch } from 'vue' import { nextTick, onMounted, ref, watch } from 'vue'
import type { WaveformAxesOptions } from '../../types' import type { WaveformAxesOptions } from '../../types'
import { formatAxisTime, formatScientificAxisLabel } from '../../utils' import { formatScientificAxisLabel, formatXAxisLabel } from '../../utils'
import type { DisplaySeries, TrackLayout, WaveformYAxisLayout } from '../core/types' import type { DisplaySeries, TrackLayout, WaveformYAxisLayout } from '../core/types'
interface Props { interface Props {
@@ -43,8 +43,11 @@ function renderAxes() {
const element = yAxisElements.value[index] const element = yAxisElements.value[index]
if (!element) return if (!element) return
const [axisMin, axisMax] = axis.scale.domain() const [axisMin, axisMax] = axis.scale.domain()
const topTickValue = Math.max(...axis.tickValues)
const yAxis = (axis.side === 'left' ? axisLeft(axis.scale) : axisRight(axis.scale)) 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) .tickSize(-4)
.tickPadding(7) .tickPadding(7)
.tickSizeOuter(0) .tickSizeOuter(0)
@@ -63,10 +66,12 @@ function renderAxes() {
axisBottom(props.track.xScale) axisBottom(props.track.xScale)
.tickValues(props.track.xAxisTickValues) .tickValues(props.track.xAxisTickValues)
.tickFormat((value) => .tickFormat((value) =>
formatAxisTime( formatXAxisLabel(
Number(value), Number(value),
props.timeUnit,
props.track.xScale.domain() as [number, number], props.track.xScale.domain() as [number, number],
props.timeUnit,
'tick',
props.axes?.x?.labelFormatter,
), ),
) )
.tickSize(-4) .tickSize(-4)
@@ -90,6 +95,7 @@ watch(
() => props.track.xAxisTickValues, () => props.track.xAxisTickValues,
() => props.track.yAxisTickValues, () => props.track.yAxisTickValues,
() => props.timeUnit, () => props.timeUnit,
() => props.axes?.x?.labelFormatter,
() => props.axes?.x?.lineVisible, () => props.axes?.x?.lineVisible,
() => props.axes?.y?.lineVisible, () => props.axes?.y?.lineVisible,
], ],
@@ -136,17 +142,6 @@ watch(
{{ track.endpointLabels.end }} {{ track.endpointLabels.end }}
</text> </text>
</g> </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 <g
v-for="axis in track.isEmpty ? [] : track.yAxes" v-for="axis in track.isEmpty ? [] : track.yAxes"
:key="`y-axis-${track.index}-${axis.index}`" :key="`y-axis-${track.index}-${axis.index}`"
@@ -160,20 +155,6 @@ watch(
:data-y-axis-side="axis.side" :data-y-axis-side="axis.side"
:transform="`translate(${axis.x}, 0)`" :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 <g
v-if=" v-if="
!cleanView && !cleanView &&

View File

@@ -12,6 +12,7 @@ export type {
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle, WaveformTitleTextStyle,
WaveformTitleOptions, WaveformTitleOptions,
WaveformFrameStyle, WaveformFrameStyle,

View File

@@ -72,7 +72,7 @@ describe('WaveformChart', () => {
expect(endTicks[3]).toHaveLength(0) 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( const wrapper = await mountSizedChart(
{ {
kind: 'series', kind: 'series',
@@ -83,7 +83,7 @@ describe('WaveformChart', () => {
kind: 'points', kind: 'points',
points: [ points: [
{ x: 0, y: 0 }, { 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 axes = wrapper.findAll('.waveform-chart__axis--y')
const exponents = wrapper.findAll('.waveform-chart__axis-exponent--y')
expect(axes).toHaveLength(2) expect(axes).toHaveLength(2)
expect(exponents.map((label) => label.text())).toEqual(['E+02', 'E-04']) axes.forEach((axis, index) => {
axes.forEach((axis) => {
const labels = axis.findAll('.tick text').map((tick) => tick.text()) const labels = axis.findAll('.tick text').map((tick) => tick.text())
expect(labels.every((label) => !label.startsWith('E'))).toBe(true) const exponentLabels = labels.filter((label) => label.startsWith('E'))
expect(labels.every((label) => /^-?\d+\.\d{2}$/.test(label))).toBe(true) 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 () => { it('prefers a trimmed series name and falls back to yLabel for unnamed data', async () => {

View File

@@ -178,7 +178,7 @@ describe('WaveformChart', () => {
await flushPromises() await flushPromises()
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1, y: 5 }]) expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1, y: 5 }])
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true) 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') const crosshairLines = wrapper.findAll('.waveform-chart__crosshair line')
expect(crosshairLines).toHaveLength(1) expect(crosshairLines).toHaveLength(1)
expect(crosshairLines[0].attributes('x1')).toBe(crosshairLines[0].attributes('x2')) expect(crosshairLines[0].attributes('x1')).toBe(crosshairLines[0].attributes('x2'))
@@ -303,7 +303,7 @@ describe('WaveformChart', () => {
flushAnimationFrames() flushAnimationFrames()
await flushPromises() 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.findAll('.waveform-chart__crosshair line')).toHaveLength(1)
expect(wrapper.get('.waveform-chart__line').element).toBe(pathBeforeHover) expect(wrapper.get('.waveform-chart__line').element).toBe(pathBeforeHover)
expect(chartUpdate).not.toHaveBeenCalled() expect(chartUpdate).not.toHaveBeenCalled()

View File

@@ -62,15 +62,15 @@ describe('WaveformChart', () => {
], ],
} }
const wrapper = await mountSizedChart(firstData) 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 }) firstData.points.push({ x: 2, y: 2 })
await flushPromises() 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 wrapper.setProps({ data: { ...firstData, points: [...firstData.points] } })
await flushPromises() 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 () => { 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__track-label')).toHaveLength(0)
expect( expect(
wrapper.findAll('.waveform-chart__axis-endpoint--end').map((item) => item.text()), 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 () => { it('keeps the zero Y-axis label on upper compact tracks', async () => {

View File

@@ -1,7 +1,8 @@
import { flushPromises } from '@vue/test-utils' 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 { flushAnimationFrames } from '../../test/setup'
import type { WaveformXAxisLabelFormatter } from '../../types'
import { type WaveformData } from '../waveform' import { type WaveformData } from '../waveform'
import { gridSeries, mountSizedChart } from '../../test/waveformChart' import { gridSeries, mountSizedChart } from '../../test/waveformChart'
@@ -162,35 +163,42 @@ describe('WaveformChart', () => {
expect(wrapper.get('.waveform-chart__axis-endpoint--end').attributes('x')).toBe( expect(wrapper.get('.waveform-chart__axis-endpoint--end').attributes('x')).toBe(
String(trackWidth), String(trackWidth),
) )
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4.99') expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4990.3')
expect(wrapper.get('.waveform-chart__axis-exponent--x').text()).toBe('E+03') 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 () => { it('uses one shared scientific exponent only for large and tiny Y-axis domains', async () => {
const cases = [ const cases = [
{ values: [0, 50], exponent: null }, { values: [0, 50], yDomain: [0, 50] as [number, number], exponent: null },
{ values: [0, 254], exponent: 'E+02' }, { values: [1000, 3000], yDomain: [1000, 3000] as [number, number], exponent: 'E+03' },
{ values: [0, 0.0002], exponent: 'E-04' }, {
values: [0.0001, 0.0003],
yDomain: [0.0001, 0.0003] as [number, number],
exponent: 'E-04',
},
] ]
for (const { values, exponent } of cases) { for (const { values, yDomain, exponent } of cases) {
const wrapper = await mountSizedChart({ const data = {
kind: 'points', kind: 'points' as const,
points: values.map((y, x) => ({ x, y })), points: values.map((y, x) => ({ x, y })),
}) }
const originalValues = data.points.map((point) => point.y)
const wrapper = await mountSizedChart(data, { yDomain })
const labels = wrapper const labels = wrapper
.get('.waveform-chart__axis--y') .get('.waveform-chart__axis--y')
.findAll('.tick text') .findAll('.tick text')
.map((tick) => 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) { if (exponent === null) {
expect(exponentLabel.exists()).toBe(false) expect(exponentLabels).toHaveLength(0)
} else { } else {
expect(exponentLabel.text()).toBe(exponent) expect(exponentLabels).toHaveLength(1)
expect(labels.every((label) => !label.startsWith('E'))).toBe(true) expect(exponentLabels[0]).toMatch(new RegExp(`^${exponent.replace('+', '\\+')} `))
expect(labels.every((label) => /^-?\d+\.\d{2}$/.test(label))).toBe(true)
} }
expect(wrapper.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
expect(data.points.map((point) => point.y)).toEqual(originalValues)
wrapper.unmount() wrapper.unmount()
} }
@@ -212,13 +220,13 @@ describe('WaveformChart', () => {
expect(millisecondsChart.text()).toContain('时间ms') expect(millisecondsChart.text()).toContain('时间ms')
expect(millisecondTicks.length).toBeGreaterThan(0) expect(millisecondTicks.length).toBeGreaterThan(0)
expect(millisecondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00') expect(millisecondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
expect(millisecondsChart.get('.waveform-chart__axis-exponent--x').text()).toBe('E+03') expect(millisecondsChart.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
expect(millisecondsChart.find('.waveform-chart__watermark').exists()).toBe(false) expect(millisecondsChart.find('.waveform-chart__watermark').exists()).toBe(false)
const secondsChart = await mountSizedChart(data, { timeUnit: 's', xLabel: 'Elapsed time' }) const secondsChart = await mountSizedChart(data, { timeUnit: 's', xLabel: 'Elapsed time' })
expect(secondsChart.text()).toContain('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) 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('x')).toBe('0')
expect(start.attributes('text-anchor')).toBe('start') expect(start.attributes('text-anchor')).toBe('start')
expect(start.text()).toBe('0.00') expect(start.text()).toBe('0')
expect(end.attributes('x')).toBe( expect(end.attributes('x')).toBe(
wrapper.get('.waveform-chart__track').attributes('data-track-width'), wrapper.get('.waveform-chart__track').attributes('data-track-width'),
) )
expect(end.attributes('text-anchor')).toBe('end') 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.length).toBeGreaterThan(0)
expect(middleTickLabels).not.toContain('0.00') expect(middleTickLabels).not.toContain('0')
expect(wrapper.get('.waveform-chart__axis-exponent--x').text()).toBe('E+03') expect(wrapper.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
expect(wrapper.findAll('.waveform-chart__grid--major line').length).toBeGreaterThan( expect(wrapper.findAll('.waveform-chart__grid--major line').length).toBeGreaterThan(
middleTickLabels.length, middleTickLabels.length,
) )
@@ -260,6 +268,70 @@ describe('WaveformChart', () => {
expect(endpointGroup.attributes('font-size')).toBe(xAxis.attributes('font-size')) 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 () => { it('keeps zoom-change domains in source seconds', async () => {
const wrapper = await mountSizedChart({ const wrapper = await mountSizedChart({
kind: 'points', 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--start').text()).not.toBe(initialStart)
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).not.toBe(initialEnd) 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)
}) })
}) })

View File

@@ -59,7 +59,7 @@ describe('WaveformChart', () => {
await flushPromises() await flushPromises()
expect(wrapper.findAll('.waveform-chart__axis--x')).toHaveLength(1) 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 () => { it('updates rendering props and disables zoom interaction', async () => {

View File

@@ -33,10 +33,10 @@ describe('WaveformChart', () => {
tracks[0].get('.waveform-chart__y-axis-label-bg').attributes('x'), 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(labelBackgroundX).toBe(labelX - 12)
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(119) expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(90)
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(119) expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(90)
}) })
it('keeps a tick-only gutter when channel labels are empty', async () => { 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')) const secondLeft = Number(tracks[1].attributes('data-track-left'))
expect(wrapper.findAll('.waveform-chart__y-axis-label')).toHaveLength(0) expect(wrapper.findAll('.waveform-chart__y-axis-label')).toHaveLength(0)
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(89) expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(64)
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(89) expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(60)
}) })
it('keeps the Y-axis label gutter stable while paging between value ranges', async () => { 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 tracks = wrapper.findAll('.waveform-chart__track')
const firstWidth = Number(tracks[0].attributes('data-track-width')) const firstWidth = Number(tracks[0].attributes('data-track-width'))
const secondLeft = Number(tracks[1].attributes('data-track-left')) 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 () => { 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') 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--start').text()).toBe('-8000')
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5.00') expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000')
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8.00') expect(tracks[1]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8000')
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5.00') 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 () => { 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 () => { 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 wrapper.setProps({ yDomain: [3, 97] })
await flushPromises() await flushPromises()
expect(yTickLabels()).toContain('3.00') expect(yTickLabels()).toContain('3')
expect(yTickLabels()).toContain('97.00') expect(yTickLabels()).toContain('97')
await wrapper.setProps({ yDomain: undefined }) await wrapper.setProps({ yDomain: undefined })
await flushPromises() await flushPromises()
expect(yTickLabels()).not.toContain('3.00') expect(yTickLabels()).not.toContain('3')
expect(yTickLabels()).not.toContain('97.00') expect(yTickLabels()).not.toContain('97')
}) })
it('keeps annotations bound to their channel while paging', async () => { it('keeps annotations bound to their channel while paging', async () => {

View File

@@ -30,7 +30,7 @@ describe('WaveformChart', () => {
kind: 'points', kind: 'points',
points: [ points: [
{ x: 0, y: 0 }, { x: 0, y: 0 },
{ x: 1, y: 254 }, { x: 1, y: 2540 },
], ],
}, },
}, },
@@ -51,13 +51,13 @@ describe('WaveformChart', () => {
) )
expect( expect(
wrapper wrapper.findAll('.waveform-chart__axis--y').map((axis) =>
.findAll('.waveform-chart__axis-exponent--y') axis
.map((label) => [label.attributes('data-y-axis-index'), label.text()]), .findAll('.tick text')
).toEqual([ .map((label) => label.text())
['1', 'E+02'], .filter((label) => label.startsWith('E')),
['2', 'E-04'], ),
]) ).toEqual([[], [expect.stringMatching(/^E\+03 /)], [expect.stringMatching(/^E-04 /)]])
}) })
it('reprojects annotations with the Y axis assigned to their series', async () => { it('reprojects annotations with the Y axis assigned to their series', async () => {

View File

@@ -54,8 +54,9 @@ describe('WaveformChart', () => {
], ],
}) })
expect(first.find('.waveform-chart__axis-exponent--y').exists()).toBe(false) expect(first.get('.waveform-chart__axis--y').text()).not.toContain('E')
expect(second.get('.waveform-chart__axis-exponent--y').text()).toBe('E+04') 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 () => { 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(xAxis.get('path.domain').attributes('display')).toBe('none')
expect(wrapper.findAll('.waveform-chart__axis-endpoint')).toHaveLength(2) expect(wrapper.findAll('.waveform-chart__axis-endpoint')).toHaveLength(2)
expect(wrapper.get('.waveform-chart__axis-exponent--x').text()).not.toBe('') expect(wrapper.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
expect(wrapper.get('.waveform-chart__axis-exponent--y').text()).not.toBe('') 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.find('.waveform-chart__grid').exists()).toBe(false)
expect(wrapper.get('.waveform-chart__plot-frame').attributes()).toMatchObject({ expect(wrapper.get('.waveform-chart__plot-frame').attributes()).toMatchObject({
stroke: '#dc2626', stroke: '#dc2626',

View File

@@ -105,6 +105,65 @@ describe('WaveformChart', () => {
expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('300') expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('300')
}) })
it('configures plot top and bottom margins and reacts to updates', async () => {
const wrapper = await mountSizedChart(
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
{
displayMode: 'compact',
grid: { rowCount: 1, columnCount: 1 },
plotMargin: { top: 30, bottom: 70 },
},
)
expect(wrapper.attributes('data-plot-margin-top')).toBe('30')
expect(wrapper.attributes('data-plot-margin-bottom')).toBe('70')
expect(wrapper.get('.waveform-chart__overlay').attributes('height')).toBe('260')
expect(wrapper.get('.waveform-chart__svg > g').attributes('transform')).toContain(', 30)')
await wrapper.setProps({ plotMargin: { top: 12 } })
expect(wrapper.attributes('data-plot-margin-top')).toBe('12')
expect(wrapper.attributes('data-plot-margin-bottom')).toBe('52')
expect(wrapper.get('.waveform-chart__overlay').attributes('height')).toBe('296')
})
it('falls back to default plot margins for invalid values', async () => {
const wrapper = await mountSizedChart(
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
{
displayMode: 'compact',
grid: { rowCount: 1, columnCount: 1 },
plotMargin: { top: -1, bottom: Number.NaN },
},
)
expect(wrapper.attributes('data-plot-margin-top')).toBe('18')
expect(wrapper.attributes('data-plot-margin-bottom')).toBe('52')
expect(wrapper.get('.waveform-chart__overlay').attributes('height')).toBe('290')
})
it('keeps the chart title and X-axis title fixed when plot margins change', async () => {
const wrapper = await mountSizedChart(
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
{
displayMode: 'compact',
grid: { rowCount: 1, columnCount: 1 },
title: { text: '固定标题' },
},
)
const titleAreaStyle = wrapper.get('.waveform-chart__title-area').attributes('style')
const xAxisTitle = wrapper.get('.waveform-chart__x-label')
const initialX = xAxisTitle.attributes('x')
const initialY = xAxisTitle.attributes('y')
await wrapper.setProps({ plotMargin: { top: 60, bottom: 90 } })
expect(wrapper.get('.waveform-chart__title-area').attributes('style')).toBe(titleAreaStyle)
expect(wrapper.get('.waveform-chart__x-label').attributes('x')).toBe(initialX)
expect(wrapper.get('.waveform-chart__x-label').attributes('y')).toBe(initialY)
expect(wrapper.get('.waveform-chart__svg > g').attributes('transform')).toContain(', 60)')
})
it('does not render or reserve space for missing, hidden, or blank titles', async () => { it('does not render or reserve space for missing, hidden, or blank titles', async () => {
for (const title of [undefined, { visible: false, text: '隐藏标题' }, { text: ' ' }]) { for (const title of [undefined, { visible: false, text: '隐藏标题' }, { text: ' ' }]) {
const wrapper = await mountSizedChart( const wrapper = await mountSizedChart(

View File

@@ -79,8 +79,8 @@ describe('WaveformChart', () => {
expect(wrapper.emitted('zoom-change')).toBeUndefined() expect(wrapper.emitted('zoom-change')).toBeUndefined()
expect(wrapper.emitted('zoom-end')).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--start').text()).toBe('0')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00') expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
}) })
it('ignores independent viewport dragging', async () => { it('ignores independent viewport dragging', async () => {
@@ -333,14 +333,14 @@ describe('WaveformChart', () => {
) )
flushAnimationFrames() flushAnimationFrames()
await flushPromises() 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 })) overlay.element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
await flushPromises() await flushPromises()
expect(wrapper.emitted('zoom-reset')).toHaveLength(1) 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--start').text()).toBe('0')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00') expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
}) })
it('exposes resetViewport for independent tracks', async () => { it('exposes resetViewport for independent tracks', async () => {
@@ -364,13 +364,13 @@ describe('WaveformChart', () => {
) )
flushAnimationFrames() flushAnimationFrames()
await flushPromises() 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 } const chart = wrapper.vm as unknown as { resetViewport: () => void }
chart.resetViewport() chart.resetViewport()
await flushPromises() await flushPromises()
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0.00') expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0')
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1.00') expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1000')
}) })
}) })

View File

@@ -49,15 +49,15 @@ describe('WaveformChart wheel zoom out', () => {
end: wrapper.get('.waveform-chart__axis-endpoint--end').text(), 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) 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 wrapper.setProps({ data: createData(-0.125, 0.125) })
await flushPromises() await flushPromises()
await dispatchWheel(4000) 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 () => { it('emits one zoom-end payload after zooming a shared viewport out', async () => {

View File

@@ -45,6 +45,7 @@ defineExpose({ resetViewport })
:clean-view="model.cleanView" :clean-view="model.cleanView"
:presentation-mode="model.presentationMode" :presentation-mode="model.presentationMode"
:show-tooltip="model.showTooltip" :show-tooltip="model.showTooltip"
:plot-margin="model.plotMargin"
:zero-line="model.zeroLine" :zero-line="model.zeroLine"
:frame-number="model.frameWatermarkVisible ? 1 : undefined" :frame-number="model.frameWatermarkVisible ? 1 : undefined"
:annotations-visible="model.annotationsVisible" :annotations-visible="model.annotationsVisible"

View File

@@ -8,6 +8,35 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
</script> </script>
<template> <template>
<section class="control-section">
<h2>绘图区边距</h2>
<div class="plot-margin-controls">
<label class="frame-style-control">
<span>上边距</span>
<InputNumber
v-model:value="model.plotMarginTop"
:min="0"
:max="300"
:step="1"
addon-after="px"
size="small"
aria-label="绘图区上边距"
/>
</label>
<label class="frame-style-control">
<span>下边距</span>
<InputNumber
v-model:value="model.plotMarginBottom"
:min="0"
:max="300"
:step="1"
addon-after="px"
size="small"
aria-label="绘图区下边距"
/>
</label>
</div>
</section>
<section class="control-section"> <section class="control-section">
<div class="control-section__header"> <div class="control-section__header">
<h2>零值参考线</h2> <h2>零值参考线</h2>
@@ -96,6 +125,76 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
<Switch v-model:checked="model.yAxisLineVisible" size="small" aria-label="显示纵轴线" /> <Switch v-model:checked="model.yAxisLineVisible" size="small" aria-label="显示纵轴线" />
</div> </div>
</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>
<section class="control-section"> <section class="control-section">
<h2>图框样式</h2> <h2>图框样式</h2>

View File

@@ -0,0 +1,29 @@
import { flushPromises, mount } from '@vue/test-utils'
import { InputNumber } from 'ant-design-vue'
import { describe, expect, it } from 'vitest'
import App from '../App.vue'
import { WaveformChart } from '../components'
describe('plot margin demo controls', () => {
it('updates the chart top and bottom margins from the sidebar', async () => {
const wrapper = mount(App)
await flushPromises()
const chart = wrapper.getComponent(WaveformChart)
const controls = wrapper.get('.plot-margin-controls').findAllComponents(InputNumber)
expect(controls).toHaveLength(2)
expect(controls[0]?.props('value')).toBe(18)
expect(controls[1]?.props('value')).toBe(52)
expect(chart.props('plotMargin')).toEqual({ top: 18, bottom: 52 })
controls[0]?.vm.$emit('update:value', 30)
controls[1]?.vm.$emit('update:value', 70)
await flushPromises()
expect(chart.props('plotMargin')).toEqual({ top: 30, bottom: 70 })
expect(wrapper.get('.waveform-chart').attributes('data-plot-margin-top')).toBe('30')
expect(wrapper.get('.waveform-chart').attributes('data-plot-margin-bottom')).toBe('70')
wrapper.unmount()
})
})

View File

@@ -10,10 +10,12 @@ import type {
WaveformLegendPosition, WaveformLegendPosition,
WaveformLineStyle, WaveformLineStyle,
WaveformOverlayMode, WaveformOverlayMode,
WaveformPlotMargin,
WaveformTitleOptions, WaveformTitleOptions,
WaveformZeroLineOptions, WaveformZeroLineOptions,
WaveformZoomEndPayload, WaveformZoomEndPayload,
} from '../components' } from '../components'
import type { DemoXAxisLabelFormat, DemoXAxisLabelTimeZone } from './useDemoXAxisLabelControls'
interface SelectOption<T> { interface SelectOption<T> {
label: string label: string
@@ -25,6 +27,8 @@ export interface DemoControlPanelModel {
displayMode: WaveformDisplayMode displayMode: WaveformDisplayMode
overlayMode: WaveformOverlayMode overlayMode: WaveformOverlayMode
showTooltip: boolean showTooltip: boolean
plotMarginTop: number
plotMarginBottom: number
cleanView: boolean cleanView: boolean
presentationMode: boolean presentationMode: boolean
selectedSeriesId: string selectedSeriesId: string
@@ -44,6 +48,14 @@ export interface DemoControlPanelModel {
verticalGridColor: string verticalGridColor: string
xAxisLineVisible: boolean xAxisLineVisible: boolean
yAxisLineVisible: 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 frameBorderColor: string
frameBackgroundColor: string frameBackgroundColor: string
frameBorderWidth: number frameBorderWidth: number
@@ -90,6 +102,7 @@ export interface DemoChartModel {
cleanView: boolean cleanView: boolean
presentationMode: boolean presentationMode: boolean
showTooltip: boolean showTooltip: boolean
plotMargin: WaveformPlotMargin
zeroLine: WaveformZeroLineOptions zeroLine: WaveformZeroLineOptions
frameWatermarkVisible: boolean frameWatermarkVisible: boolean
annotations: WaveformAnnotation[] annotations: WaveformAnnotation[]

View 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,
}
}

View 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()
})
})

View File

@@ -17,12 +17,16 @@ export type {
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle, WaveformTitleTextStyle,
WaveformTitleOptions, WaveformTitleOptions,
WaveformLegendPosition, WaveformLegendPosition,
WaveformLegendOrientation, WaveformLegendOrientation,
WaveformLegendOptions, WaveformLegendOptions,
WaveformFrameStyle, WaveformFrameStyle,
WaveformXAxisLabelKind,
WaveformXAxisLabelFormatterContext,
WaveformXAxisLabelFormatter,
WaveformAxesOptions, WaveformAxesOptions,
WaveformZeroLineOptions, WaveformZeroLineOptions,
// 数据类型 // 数据类型

View File

@@ -133,6 +133,14 @@ body {
grid-template-columns: minmax(0, 1fr) auto; 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,
.grid-line-color-picker .vc-color-wrap { .grid-line-color-picker .vc-color-wrap {
width: 48px; width: 48px;
@@ -187,6 +195,7 @@ body {
} }
.frame-style-controls, .frame-style-controls,
.plot-margin-controls,
.auxiliary-style-controls { .auxiliary-style-controls {
display: grid; display: grid;
gap: 10px; gap: 10px;

View File

@@ -74,6 +74,14 @@ export interface WaveformRenderingOptions {
errorBarMinSpacing?: number errorBarMinSpacing?: number
} }
/** Pixel margins reserved above and below the waveform plotting area. */
export interface WaveformPlotMargin {
/** Space between the top of the drawing SVG and the plotting area. Defaults to 18. */
top?: number
/** Space between the plotting area and the bottom of the drawing SVG. Defaults to 52. */
bottom?: number
}
/** Text styling for the chart-level title. */ /** Text styling for the chart-level title. */
export interface WaveformTitleTextStyle { export interface WaveformTitleTextStyle {
color?: string color?: string
@@ -121,10 +129,31 @@ export interface WaveformFrameStyle {
backgroundColor?: string 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. */ /** Controls axis baseline visibility while preserving tick marks and axis text. */
export interface WaveformAxesOptions { export interface WaveformAxesOptions {
x?: { x?: {
lineVisible?: boolean lineVisible?: boolean
/** Formats display-unit X values for ticks and visible-range endpoints. */
labelFormatter?: WaveformXAxisLabelFormatter
} }
y?: { y?: {
lineVisible?: boolean lineVisible?: boolean

View File

@@ -12,12 +12,16 @@ export type {
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle, WaveformTitleTextStyle,
WaveformTitleOptions, WaveformTitleOptions,
WaveformLegendPosition, WaveformLegendPosition,
WaveformLegendOrientation, WaveformLegendOrientation,
WaveformLegendOptions, WaveformLegendOptions,
WaveformFrameStyle, WaveformFrameStyle,
WaveformXAxisLabelKind,
WaveformXAxisLabelFormatterContext,
WaveformXAxisLabelFormatter,
WaveformAxesOptions, WaveformAxesOptions,
WaveformZeroLineOptions, WaveformZeroLineOptions,
} from './chart' } from './chart'

View File

@@ -1,71 +1,112 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import type { WaveformXAxisLabelFormatter, WaveformXAxisLabelFormatterContext } from '../types'
import { import {
formatAnnotationTime, formatAnnotationTime,
formatAxisTime, formatAxisTime,
formatAxisTimeExponent,
formatEndpointTime, formatEndpointTime,
formatPlainNumber, formatPlainNumber,
formatScientificAxisExponent, formatScientificAxisExponent,
formatScientificAxisLabel, formatScientificAxisLabel,
formatTooltipNumber, formatTooltipNumber,
formatTooltipTime,
formatXAxisLabel,
resolveScientificAxisExponent,
shouldUseScientificAxisLabel, shouldUseScientificAxisLabel,
} from './formatters' } from './formatters'
describe('waveform number formatters', () => { describe('waveform number formatters', () => {
it('uses the reference Y-axis scientific notation boundaries', () => { it('uses the reference Y-axis scientific notation boundaries', () => {
expect(shouldUseScientificAxisLabel(0)).toBe(false) expect(shouldUseScientificAxisLabel(0)).toBe(false)
expect(shouldUseScientificAxisLabel(0.009)).toBe(true) expect(shouldUseScientificAxisLabel(0.000999)).toBe(true)
expect(shouldUseScientificAxisLabel(0.01)).toBe(false) expect(shouldUseScientificAxisLabel(0.001)).toBe(false)
expect(shouldUseScientificAxisLabel(99.99)).toBe(false) expect(shouldUseScientificAxisLabel(999.999)).toBe(false)
expect(shouldUseScientificAxisLabel(100)).toBe(true) expect(shouldUseScientificAxisLabel(1000)).toBe(true)
}) })
it('shares one separate exponent across an axis', () => { it('prefixes one shared exponent to the largest visible tick', () => {
const positiveAxis = { axisMin: 0, axisMax: 254 } const positiveAxis = { axisMin: 1000, axisMax: 3000 }
expect(formatScientificAxisLabel(127, positiveAxis)).toBe('1.27') expect(formatScientificAxisLabel(1000, positiveAxis)).toBe('1')
expect(formatScientificAxisLabel(254, positiveAxis)).toBe('2.54') expect(formatScientificAxisLabel(2000, positiveAxis)).toBe('2')
expect(formatScientificAxisExponent(0, 254)).toBe('E+02') 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') expect(formatScientificAxisExponent(0, 1e120)).toBe('E+120')
const tinyAxis = { axisMin: 0, axisMax: 0.0002 } const tinyAxis = { axisMin: 0.0001, axisMax: 0.0003 }
expect(formatScientificAxisLabel(0.0001, tinyAxis)).toBe('1.00') expect(formatScientificAxisLabel(0.0001, tinyAxis)).toBe('1')
expect(formatScientificAxisLabel(0.0002, tinyAxis)).toBe('2.00') expect(formatScientificAxisLabel(0.0002, tinyAxis)).toBe('2')
expect(formatScientificAxisExponent(0, 0.0002)).toBe('E-04') expect(formatScientificAxisLabel(0.0003, tinyAxis)).toBe('E-04 3')
expect(formatScientificAxisExponent(0.0001, 0.0003)).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')
}) })
it('keeps plain axes at two decimals and removes negative zero', () => { it('derives the exponent from Math.max(axisMin, axisMax)', () => {
expect(formatScientificAxisLabel(99.99, { axisMin: 0, axisMax: 99.99 })).toBe('99.99') expect(resolveScientificAxisExponent(-9000, -1000)).toBe(3)
expect(formatScientificAxisLabel(0.01, { axisMin: 0, axisMax: 0.01 })).toBe('0.01') expect(resolveScientificAxisExponent(-10_000, 3000)).toBe(3)
expect(formatScientificAxisLabel(-0.001, { axisMin: -1, axisMax: 1 })).toBe('0.00') 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.NaN)).toBe('NaN')
expect(formatScientificAxisLabel(Number.POSITIVE_INFINITY)).toBe('Infinity') expect(formatScientificAxisLabel(Number.POSITIVE_INFINITY)).toBe('Infinity')
expect(formatScientificAxisExponent(0, 0)).toBeNull() 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] const domain: [number, number] = [0, 1]
expect(formatAxisTime(0.5, 'ms', domain)).toBe('0.50') expect(formatAxisTime(0.5004, 'ms', domain)).toBe('500.4')
expect(formatEndpointTime(1, domain, 'ms')).toBe('1.00') expect(formatEndpointTime(1, domain, 'ms')).toBe('1000')
expect(formatAxisTimeExponent(domain, 'ms')).toBe('E+03') expect(formatAxisTime(0.5, 's', domain)).toBe('0.5')
expect(formatAxisTime(0.5, 's', domain)).toBe('0.50') expect(formatEndpointTime(1, domain, 's')).toBe('1')
expect(formatEndpointTime(1, domain, 's')).toBe('1.00')
expect(formatAxisTimeExponent(domain, 's')).toBeNull()
const tinyDomain: [number, number] = [0, 0.000001] const tinyDomain: [number, number] = [0, 0.000001]
expect(formatEndpointTime(0.000001, tinyDomain, 's')).toBe('1.00') expect(formatEndpointTime(0.000001, tinyDomain, 's')).toBe('0.000001')
expect(formatAxisTimeExponent(tinyDomain, 's')).toBe('E-06') 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', () => { it('formats tooltip and raw values for their display contexts', () => {
expect(formatTooltipNumber(12345.67891)).toBe('12,345.6789') expect(formatTooltipNumber(12345.67891)).toBe('12,345.6789')
expect(formatTooltipNumber(-0)).toBe('0') expect(formatTooltipNumber(-0)).toBe('0')
expect(formatTooltipNumber(Number.POSITIVE_INFINITY)).toBe('Infinity') 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(0.0000001)).toBe('0.0000001')
expect(formatPlainNumber(1e21)).toBe('1000000000000000000000') expect(formatPlainNumber(1e21)).toBe('1000000000000000000000')
expect(formatPlainNumber(-0)).toBe('0') expect(formatPlainNumber(-0)).toBe('0')

View File

@@ -1,22 +1,26 @@
import type { WaveformXAxisLabelFormatter, WaveformXAxisLabelKind } from '../types'
/** /**
* 时间单位类型 * 时间单位类型
*/ */
export type TimeUnit = 'ms' | 's' export type TimeUnit = 'ms' | 's'
export interface ScientificAxisLabelOptions { export interface ScientificAxisLabelOptions {
/** @deprecated Y-axis labels always use five significant digits before localization. */
precision?: number precision?: number
axisMin?: number axisMin?: number
axisMax?: number axisMax?: number
}
/** @deprecated Use ScientificAxisLabelOptions. */
export type ScientificYAxisLabelOptions = ScientificAxisLabelOptions & {
topTickValue?: number topTickValue?: number
} }
const DEFAULT_Y_AXIS_PRECISION = 2 /** @deprecated Use ScientificAxisLabelOptions. */
const SCIENTIFIC_MIN_ABSOLUTE_VALUE = 0.01 export type ScientificYAxisLabelOptions = ScientificAxisLabelOptions
const SCIENTIFIC_MAX_PLAIN_ABSOLUTE_VALUE = 100
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', { const TOOLTIP_NUMBER_FORMATTER = new Intl.NumberFormat('zh-CN', {
maximumFractionDigits: 4, maximumFractionDigits: 4,
}) })
@@ -38,9 +42,10 @@ export function shouldUseScientificAxisLabel(maxAbsoluteValue: number): boolean
export function resolveScientificAxisExponent(axisMin?: number, axisMax?: number): number | null { export function resolveScientificAxisExponent(axisMin?: number, axisMax?: number): number | null {
if (typeof axisMin !== 'number' || typeof axisMax !== 'number') return null if (typeof axisMin !== 'number' || typeof axisMax !== 'number') return null
const maxAbsoluteValue = Math.max(Math.abs(axisMin), Math.abs(axisMax)) const maxValue = Math.max(axisMin, axisMax)
return shouldUseScientificAxisLabel(maxAbsoluteValue) const absoluteMaxValue = Math.abs(maxValue)
? Math.floor(Math.log10(maxAbsoluteValue)) return shouldUseScientificAxisLabel(absoluteMaxValue)
? Math.floor(Math.log10(absoluteMaxValue))
: null : null
} }
@@ -49,6 +54,13 @@ function formatExponent(exponent: number): string {
return `E${sign}${Math.abs(exponent).toString().padStart(2, '0')}` 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. */ /** Format a Y-axis tick, sharing one exponent derived from the complete axis domain. */
export function formatScientificAxisLabel( export function formatScientificAxisLabel(
value: number, value: number,
@@ -56,10 +68,13 @@ export function formatScientificAxisLabel(
): string { ): string {
if (!Number.isFinite(value)) return String(value) if (!Number.isFinite(value)) return String(value)
const precision = options.precision ?? DEFAULT_Y_AXIS_PRECISION
const exponent = resolveScientificAxisExponent(options.axisMin, options.axisMax) const exponent = resolveScientificAxisExponent(options.axisMin, options.axisMax)
const scaledValue = exponent === null ? value : value / 10 ** exponent 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. */ /** 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 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 数据域 [最小值, 最大值] * @param domain 数据域 [最小值, 最大值]
@@ -134,54 +169,33 @@ export function endpointFractionDigits(domain: [number, number], timeUnit: TimeU
} }
/** /**
* 按完整 X 轴显示域格式化端点时间 * X 轴端点时间格式化为当前显示单位下的普通十进制文本
* @param value 时间值(秒) * @param value 时间值(秒)
* @param domain 数据域 * @param _domain 数据域(为保持现有调用签名而保留)
* @param timeUnit 时间单位 * @param timeUnit 时间单位
* @returns 格式化的时间字符串 * @returns 格式化的时间字符串
*/ */
export function formatEndpointTime( export function formatEndpointTime(
value: number, value: number,
domain: [number, number], _domain: [number, number],
timeUnit: TimeUnit, timeUnit: TimeUnit,
): string { ): string {
const [axisMin, axisMax] = domain.map((domainValue) => displayTime(domainValue, timeUnit)) as [ return formatXAxisLabel(value, _domain, timeUnit, 'end')
number,
number,
]
return formatScientificAxisLabel(displayTime(value, timeUnit), {
axisMin,
axisMax,
})
} }
/** /**
* 按完整 X 轴显示域格式化时间刻度 * X 轴时间刻度格式化为当前显示单位下的普通十进制文本
* @param value 时间值(秒) * @param value 时间值(秒)
* @param timeUnit 时间单位 * @param timeUnit 时间单位
* @param _domain 数据域(为保持现有调用签名而保留)
* @returns 格式化的时间字符串 * @returns 格式化的时间字符串
*/ */
export function formatAxisTime( export function formatAxisTime(
value: number, value: number,
timeUnit: TimeUnit, timeUnit: TimeUnit,
domain?: [number, number], _domain?: [number, number],
): string { ): string {
const displayValue = displayTime(value, timeUnit) return formatXAxisLabel(value, _domain ?? [value, value], timeUnit, 'tick')
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)
} }
/** /**
@@ -191,11 +205,7 @@ export function formatAxisTimeExponent(
* @returns 格式化的时间字符串 * @returns 格式化的时间字符串
*/ */
export function formatTooltipTime(value: number, timeUnit: TimeUnit): string { export function formatTooltipTime(value: number, timeUnit: TimeUnit): string {
const displayValue = displayTime(value, timeUnit) return formatTooltipNumber(displayTime(value, timeUnit))
return displayValue.toLocaleString('zh-CN', {
minimumFractionDigits: 4,
maximumFractionDigits: 4,
})
} }
/** Format an annotation X coordinate in the selected display time unit. */ /** Format an annotation X coordinate in the selected display time unit. */

View File

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