4 Commits

Author SHA1 Message Date
李启源
8987cae846 feat(坐标轴): 支持配置纵轴等分刻度
All checks were successful
Package component / package (push) Successful in 4m43s
2026-08-18 11:45:40 +08:00
李启源
233b9b0aa2 feat(坐标轴): 支持显示纵轴单位
All checks were successful
Package component / package (push) Successful in 4m18s
2026-08-18 10:06:36 +08:00
李启源
5c71c28429 feat(视口): 支持恢复受控缩放范围
All checks were successful
Package component / package (push) Successful in 4m38s
2026-08-18 09:08:36 +08:00
李启源
7a6a7e5c26 feat(data): 扩展示例波形数据与测试覆盖
All checks were successful
Package component / package (push) Successful in 4m26s
新增示例谐波对比数据,完善图例、多轴和演示测试,并同步文档说明。
2026-08-17 14:34:15 +08:00
27 changed files with 610 additions and 116 deletions

118
README.md
View File

@@ -96,37 +96,37 @@ 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 轴模式 |
| `timeUnit` | `'s' \| 'ms'` | `'ms'` | 坐标轴和 tooltip 展示单位 | | `timeUnit` | `'s' \| 'ms'` | `'ms'` | 坐标轴和 tooltip 展示单位 |
| `xLabel` / `yLabel` | `string` | `时间timeUnit` / `'幅值'` | 坐标轴名称 | | `xLabel` / `yLabel` | `string` | `时间timeUnit` / `'幅值'` | 坐标轴名称 |
| `lineColor` | `string` | `'#0960bd'` | 单波形默认颜色 | | `lineColor` | `string` | `'#0960bd'` | 单波形默认颜色 |
| `width` / `height` | `number` | 自适应 | 组件总尺寸,单位为 CSS 像素 | | `width` / `height` | `number` | 自适应 | 组件总尺寸,单位为 CSS 像素 |
| `zoomable` / `showTooltip` | `boolean` | `true` / `true` | 缩放和数值 tooltip 开关 | | `zoomable` / `showTooltip` | `boolean` | `true` / `true` | 缩放和数值 tooltip 开关 |
| `pannable` | `boolean` | `false` | 空格拖拽平移开关 | | `pannable` | `boolean` | `false` | 空格拖拽平移开关 |
| `minZoomSpan` | `number` | 未设置 | 最小缩放跨度,使用原始 X 数据单位 | | `minZoomSpan` | `number` | 未设置 | 最小缩放跨度,使用原始 X 数据单位 |
| `minVisiblePoints` | `number` | `0` | 缩放后至少保留的不同 X 坐标数 | | `minVisiblePoints` | `number` | `0` | 缩放后至少保留的不同 X 坐标数 |
| `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围(可超出数据,空白显示) | | `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围(可超出数据,空白显示) |
| `initialXDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置初始范围(可超出数据) | | `initialXDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置初始范围(可超出数据) |
| `xDomainStrategy` | `WaveformXDomainStrategy` | `{ type: 'data' }` | 自动 X 轴视口范围策略 | | `xDomainStrategy` | `WaveformXDomainStrategy` | `{ type: 'data' }` | 自动 X 轴视口范围策略 |
| `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 轴基线显隐与 X 轴 label 格式化 | | `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线、Y 轴分割数与 X 轴 label 格式化 |
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 | | `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 | | `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
| `frameNumber` | `string \| number` | 未设置 | 图框水印内容 | | `frameNumber` | `string \| number` | 未设置 | 图框水印内容 |
| `zeroLine` | `WaveformZeroLineOptions` | `{ visible: false }` | 零值参考线显隐与样式 | | `zeroLine` | `WaveformZeroLineOptions` | `{ visible: false }` | 零值参考线显隐与样式 |
| `cleanView` | `boolean` | `false` | 保留波形、图框和刻度的净图模式 | | `cleanView` | `boolean` | `false` | 保留波形、图框和刻度的净图模式 |
| `presentationMode` | `boolean` | `false` | 禁用绘图区交互的展示模式 | | `presentationMode` | `boolean` | `false` | 禁用绘图区交互的展示模式 |
| `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 | | `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 |
| `annotationsVisible` | `boolean` | `true` | 标注图层显隐 | | `annotationsVisible` | `boolean` | `true` | 标注图层显隐 |
| `interactionMode` | `'zoom' \| 'annotation'` | `'zoom'` | 左键交互模式 | | `interactionMode` | `'zoom' \| 'annotation'` | `'zoom'` | 左键交互模式 |
| `hiddenSeriesIds` | `string[]` | 未设置 | 受控隐藏系列 ID | | `hiddenSeriesIds` | `string[]` | 未设置 | 受控隐藏系列 ID |
| `defaultHiddenSeriesIds` | `string[]` | `[]` | 非受控模式的初始隐藏系列 | | `defaultHiddenSeriesIds` | `string[]` | `[]` | 非受控模式的初始隐藏系列 |
所有公开类型均可从包入口导入,例如 `WaveformData``WaveformSeries` 所有公开类型均可从包入口导入,例如 `WaveformData``WaveformSeries`
`WaveformAnnotation``WaveformLineStyle``WaveformRenderingOptions` `WaveformAnnotation``WaveformLineStyle``WaveformRenderingOptions`
@@ -208,7 +208,8 @@ import 'waveform-analysis/style.css'
范围优先级为 `trackId` 配置、`seriesId` 配置、全局 `yDomain`、数据自动范围。上下限必须 范围优先级为 `trackId` 配置、`seriesId` 配置、全局 `yDomain`、数据自动范围。上下限必须
是两个有限且不相等的数字;倒序范围会自动调整为升序,无效配置会回退到下一优先级。 是两个有限且不相等的数字;倒序范围会自动调整为升序,无效配置会回退到下一优先级。
固定范围会精确作为坐标域使用,不会经过 D3 的 `nice()` 扩展 固定范围作为 Y 轴刻度算法的初始范围;轴会按漂亮步长经过 D3 的 `nice()` 扩展,确保主刻度
等间距。原始数据和传入的范围值不会被修改。
单值轴叠加模式会合并同一根轴上所有可见系列的有效范围;多值轴模式按系列分别使用配置, 单值轴叠加模式会合并同一根轴上所有可见系列的有效范围;多值轴模式按系列分别使用配置,
超过四根轴后复用第 4 根轴的系列会取范围并集。隐藏系列不参与公共范围合并。 超过四根轴后复用第 4 根轴的系列会取范围并集。隐藏系列不参与公共范围合并。
@@ -216,6 +217,16 @@ import 'waveform-analysis/style.css'
固定范围存在时,对应图框的 Y 轴不会被平移或视口重置覆盖X 轴缩放、平移和重置保持原有 固定范围存在时,对应图框的 Y 轴不会被平移或视口重置覆盖X 轴缩放、平移和重置保持原有
行为。运行时更新或移除 `yDomain` / `yDomains` 会立即重新布局,移除后恢复自动范围。 行为。运行时更新或移除 `yDomain` / `yDomains` 会立即重新布局,移除后恢复自动范围。
### Y 轴自动等分
Y 轴默认显示 5 个主刻度(包含上下端点),并使用类似 ECharts 的 `1 / 2 / 5 × 10ⁿ` 漂亮
步长,保证主刻度之间数值等间距。可以通过 `axes.y.splitNumber` 指定主刻度总数;每条
Y 轴会独立计算:
```vue
<WaveformChart :data="chartData" :axes="{ y: { splitNumber: 5 } }" />
```
### 缩放后按可视区间加载数据 ### 缩放后按可视区间加载数据
组件支持 Plotly 风格的矩形框选缩放:在 zoom 模式下按住鼠标左键拖拽,松开后同时缩放 组件支持 Plotly 风格的矩形框选缩放:在 zoom 模式下按住鼠标左键拖拽,松开后同时缩放
@@ -253,6 +264,20 @@ X 轴且包含多个轨道时使用按稳定 track ID 索引的 `yRanges`。平
重置组件内部缩放并触发 `zoom-reset`;调用方应在事件中取消区间请求并恢复首次完整数据。 重置组件内部缩放并触发 `zoom-reset`;调用方应在事件中取消区间请求并恢复首次完整数据。
外部重置按钮也可以通过模板引用调用组件公开的 `resetViewport()` 方法,然后执行相同的数据恢复逻辑。 外部重置按钮也可以通过模板引用调用组件公开的 `resetViewport()` 方法,然后执行相同的数据恢复逻辑。
宿主在替换局部数据后如果需要恢复之前保存的 X 视口,可以调用公开的
`setViewportDomain(domain, trackIndex?)`。传入范围使用原始秒坐标,组件会按当前数据边界、
`xDomainStrategy``minZoomSpan``minVisiblePoints` 重新约束;无效范围会被忽略。共享
X 轴模式直接传入一个范围,独立模式可以按实际 `trackIndex` 分别设置(省略 `trackIndex`
时应用到当前所有图框):
```ts
chartRef.value?.setViewportDomain(previousDomain)
chartRef.value?.setViewportDomain(trackDomain, trackIndex)
```
建议在 `data` 引用更新后调用该方法。组件也会在数据引用变更时重投影当前内部视口,避免新
数据边界使旧 transform 失效;`resetViewport(trackIndex?)` 的既有行为保持不变。
没有显式配置初始范围时,可以通过 `xDomainStrategy` 将数据范围扩展为便于阅读的视口端点。 没有显式配置初始范围时,可以通过 `xDomainStrategy` 将数据范围扩展为便于阅读的视口端点。
默认的 `{ type: 'data' }` 保持数据最小值和最大值不变;`type: 'nice'` 使用固定刻度数量计算 默认的 `{ type: 'data' }` 保持数据最小值和最大值不变;`type: 'nice'` 使用固定刻度数量计算
易读边界且只扩展视口不修改原始点位、tooltip、标注或缩放事件值 易读边界且只扩展视口不修改原始点位、tooltip、标注或缩放事件值
@@ -284,6 +309,34 @@ X 轴且包含多个轨道时使用按稳定 track ID 索引的 `yRanges`。平
Tooltip 每个系列按 `炮号:通道 (x:值 y:值)` 格式显示。`WaveformSeries.shotNo` 为空或未提供时, Tooltip 每个系列按 `炮号:通道 (x:值 y:值)` 格式显示。`WaveformSeries.shotNo` 为空或未提供时,
炮号显示为“未配置炮号”Tooltip 不显示单位和误差附加文本。 炮号显示为“未配置炮号”Tooltip 不显示单位和误差附加文本。
### 纵轴单位
可在每个 `WaveformSeries` 上设置可选的 `unit`,单位会显示在对应 Y 轴的顶部刻度中:
```ts
const chartData = {
kind: 'series',
series: [
{
id: 'voltage',
name: '电压',
unit: 'V',
data: {
kind: 'points',
points: [
{ x: 0, y: 1200 },
{ x: 1, y: 3000 },
],
},
},
],
} satisfies WaveformData
```
当 Y 轴使用科学计数法时,顶部刻度格式为 `E+03 (V) 3`;未使用科学计数法时,格式为
`(V) 3`。单位为空、全为空白或未配置时,不显示 `(单位)`,例如仍显示为 `E+03 3``3`
多 Y 轴模式下,每根轴使用该轴首个 series 的单位。
### 线型、点型与误差棒 ### 线型、点型与误差棒
每条序列可以独立设置连线方式、数据点符号和误差棒: 每条序列可以独立设置连线方式、数据点符号和误差棒:
@@ -415,7 +468,8 @@ Demo 左侧控制面板提供标题实时预览,可配置标题名称、显隐
### 图例与曲线显隐 ### 图例与曲线显隐
`legend.backgroundColor` 设置多曲线图例的背景颜色。该字段接受任意有效 CSS 颜色值, 每个图框独立管理自己的图例:图框内有两条或更多曲线时显示图例,只有一条曲线时不显示。
`legend.backgroundColor` 设置图例的背景颜色。该字段接受任意有效 CSS 颜色值,
可通过 `rgba(...)``hsla(...)` 中的 alpha 通道调整透明度: 可通过 `rgba(...)``hsla(...)` 中的 alpha 通道调整透明度:
```vue ```vue
@@ -654,7 +708,7 @@ async function importAnnotationFile(file: File) {
字段无效时会抛出 `TypeError`,不会返回部分结果。导入包含未知 `seriesId` 的标注是允许的, 字段无效时会抛出 `TypeError`,不会返回部分结果。导入包含未知 `seriesId` 的标注是允许的,
对应曲线加载后会恢复显示。文件选择、错误提示和下载由业务层实现。 对应曲线加载后会恢复显示。文件选择、错误提示和下载由业务层实现。
X 轴刻度和左右端点先按 `timeUnit` 转换为秒或毫秒,再显示为不带分组符和科学计数法的完整普通十进制值,并可通过 `axes.x.labelFormatter` 自定义。Y 轴会根据完整显示域选择格式:最大绝对值在 `[0.01, 100)` 时显示两位普通小数;大于等于 `100`,或大于 `0` 且小于 `0.01` 时,刻度显示两位缩放值,并在轴末端单独显示共享倍率 `E±NN`,多 Y 轴分别计算倍率。tooltip 使用最多 4 位小数的本地化普通数字并省略无意义尾零;标注编辑器的 X 坐标跟随 `timeUnit` 并固定 3 位小数Y 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。 X 轴刻度和左右端点先按 `timeUnit` 转换为秒或毫秒,再显示为不带分组符和科学计数法的完整普通十进制值,并可通过 `axes.x.labelFormatter` 自定义。Y 轴会根据完整显示域选择格式:最大绝对值在 `[0.01, 100)` 时显示两位普通小数;大于等于 `100`,或大于 `0` 且小于 `0.01` 时,刻度显示两位缩放值,并在顶部刻度单独显示共享倍率 `E±NN`。如果 series 配置了 `unit`,顶部刻度会在倍率后追加 `(单位)`;没有科学计数法时也会显示 `(单位)`,多 Y 轴分别使用对应轴首个 series 的单位。tooltip 使用最多 4 位小数的本地化普通数字并省略无意义尾零;标注编辑器的 X 坐标跟随 `timeUnit` 并固定 3 位小数Y 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。
标注框默认布局在采样点正上方,只做绘图区边界裁剪;文本框通过连接箭头指向标注位置,多个标注重叠时可通过拖动手动避让。 标注框默认布局在采样点正上方,只做绘图区边界裁剪;文本框通过连接箭头指向标注位置,多个标注重叠时可通过拖动手动避让。
## 事件 ## 事件

View File

@@ -1,6 +1,6 @@
{ {
"name": "waveform-analysis", "name": "waveform-analysis",
"version": "0.1.37", "version": "0.1.41",
"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

@@ -235,7 +235,7 @@ describe('App workspace layout', { timeout: 20_000 }, () => {
tracks.map((track) => tracks.map((track) =>
track.findAll('.waveform-chart__series').map((item) => item.attributes('data-series-name')), track.findAll('.waveform-chart__series').map((item) => item.attributes('data-series-name')),
), ),
).toEqual([['正弦基波'], ['谐波扰动'], ['阻尼振荡'], ['阶跃响应']]) ).toEqual([['正弦基波'], ['谐波扰动', '谐波对比'], ['阻尼振荡'], ['阶跃响应']])
expect( expect(
tracks tracks
.slice(1) .slice(1)
@@ -253,6 +253,7 @@ describe('App workspace layout', { timeout: 20_000 }, () => {
expect(simulatedSeries.map((item) => item.name)).toEqual([ expect(simulatedSeries.map((item) => item.name)).toEqual([
'正弦基波', '正弦基波',
'谐波扰动', '谐波扰动',
'谐波对比',
'阻尼振荡', '阻尼振荡',
'阶跃响应', '阶跃响应',
'脉冲响应', '脉冲响应',
@@ -321,7 +322,6 @@ describe('App workspace layout', { timeout: 20_000 }, () => {
const styleSelect = frameControls.findAllComponents(Select)[0] const styleSelect = frameControls.findAllComponents(Select)[0]
expect(colorPickers).toHaveLength(2) expect(colorPickers).toHaveLength(2)
const initialFrames = wrapper.findAll('.waveform-chart__plot-frame') const initialFrames = wrapper.findAll('.waveform-chart__plot-frame')
expect(initialFrames.length > 1 && initialFrames.every((frame) => frame.attributes('stroke-width') === '2')).toBe(true) expect(initialFrames.length > 1 && initialFrames.every((frame) => frame.attributes('stroke-width') === '2')).toBe(true)

View File

@@ -36,6 +36,7 @@ const controller = useWaveformChartController(props as ResolvedWaveformChartProp
defineExpose({ defineExpose({
resetViewport: controller.resetViewport, resetViewport: controller.resetViewport,
setViewportDomain: controller.setViewportDomain,
}) })
</script> </script>

View File

@@ -35,7 +35,7 @@ function track(seriesList: DisplaySeries[]): DisplayTrack {
} }
describe('fixed Y-domain layout', () => { describe('fixed Y-domain layout', () => {
it('uses an exact global fixed domain without applying nice bounds', () => { it('expands a global fixed domain to nice equal intervals', () => {
const sourceTrack = track([series('a', 0, 100)]) const sourceTrack = track([series('a', 0, 100)])
const result = buildTrackLayouts({ const result = buildTrackLayouts({
cells: [ cells: [
@@ -66,10 +66,80 @@ describe('fixed Y-domain layout', () => {
showCompactEmptyTracks: false, showCompactEmptyTracks: false,
})[0] })[0]
expect(result?.yScale.domain()).toEqual([3, 97]) expect(result?.yScale.domain()).toEqual([0, 100])
expect(result?.yAxes[0]?.tickValues).toContain(3) expect(result?.yAxes[0]?.tickValues).toContain(0)
expect(result?.yAxes[0]?.tickValues).toContain(97) expect(result?.yAxes[0]?.tickValues).toContain(100)
expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([3, 97]) expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([0, 100])
})
it('uses the configured split number for fixed domains', () => {
const result = buildTrackLayouts({
cells: [
{
slotIndex: 0,
row: 0,
column: 0,
left: 0,
top: 0,
width: 120,
height: 100,
plotHeight: 100,
cellHeight: 130,
xAxisBand: 30,
series: track([series('a', 0, 100)]),
},
],
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
displayMode: 'independent',
overlayMode: 'single-axis',
independentTransforms: [zoomIdentity],
sharedZoomDomain: [0, 1],
fixedYDomain: [3, 97],
yAxisSplitNumber: 5,
timeUnit: 'ms',
rendering: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
hideSecondaryLabels: false,
yAxisLabelX: -50,
showCompactEmptyTracks: false,
})[0]
expect(result?.yAxes[0]?.majorTicks).toEqual([0, 25, 50, 75, 100])
})
it('defaults to five ticks and supports a two-tick axis', () => {
const build = (splitNumber?: number) =>
buildTrackLayouts({
cells: [
{
slotIndex: 0,
row: 0,
column: 0,
left: 0,
top: 0,
width: 120,
height: 100,
plotHeight: 100,
cellHeight: 130,
xAxisBand: 30,
series: track([series('a', 3, 97)]),
},
],
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
displayMode: 'independent',
overlayMode: 'single-axis',
independentTransforms: [zoomIdentity],
sharedZoomDomain: [0, 1],
fixedYDomain: [3, 97],
yAxisSplitNumber: splitNumber,
timeUnit: 'ms',
rendering: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
hideSecondaryLabels: false,
yAxisLabelX: -50,
showCompactEmptyTracks: false,
})[0]?.yAxes[0]?.majorTicks
expect(build()).toHaveLength(5)
expect(build(2)).toEqual([0, 100])
}) })
it('resolves track, series, and global fixed-domain precedence', () => { it('resolves track, series, and global fixed-domain precedence', () => {

View File

@@ -9,6 +9,7 @@ import {
findClosestTrackAtPointer, findClosestTrackAtPointer,
MAX_MULTI_Y_AXIS_COUNT, MAX_MULTI_Y_AXIS_COUNT,
measureYAxisGroupClearance, measureYAxisGroupClearance,
resolveYAxisTickCount,
} from './layout' } from './layout'
function series(id: string, minimum: number, maximum: number): DisplaySeries { function series(id: string, minimum: number, maximum: number): DisplaySeries {
@@ -119,8 +120,15 @@ describe('multi-value Y-axis grouping', () => {
showCompactEmptyTracks: false, showCompactEmptyTracks: false,
})[0] })[0]
expect(result?.yScale.domain()).toEqual([25, 75]) expect(result?.yScale.domain()).toEqual([20, 80])
expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([25, 75]) expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([20, 80])
})
it('normalizes configured Y-axis split counts', () => {
expect(resolveYAxisTickCount(100, 0)).toBe(2)
expect(resolveYAxisTickCount(100, 4.8)).toBe(4)
expect(resolveYAxisTickCount(100, Number.NaN)).toBe(5)
expect(resolveYAxisTickCount(220)).toBe(5)
}) })
it('keeps every overlaid series on one axis in single-axis mode', () => { it('keeps every overlaid series on one axis in single-axis mode', () => {

View File

@@ -20,6 +20,13 @@ const Y_AXIS_OUTER_PADDING = 4
const Y_AXIS_LABEL_GAP = 6 const Y_AXIS_LABEL_GAP = 6
const Y_AXIS_LABEL_BAND_WIDTH = 24 const Y_AXIS_LABEL_BAND_WIDTH = 24
export function resolveYAxisTickCount(_plotHeight: number, splitNumber?: number): number {
if (typeof splitNumber === 'number' && Number.isFinite(splitNumber)) {
return Math.max(2, Math.floor(splitNumber))
}
return 5
}
export interface YAxisSeriesGroup { export interface YAxisSeriesGroup {
index: number index: number
side: 'left' | 'right' side: 'left' | 'right'
@@ -103,16 +110,22 @@ export function axisTextMetrics(
domain: [number, number], domain: [number, number],
nice = true, nice = true,
tickValues?: number[], tickValues?: number[],
unit?: string,
tickCount = 10,
): { tickTextWidth: number } { ): { tickTextWidth: number } {
const effectiveTickCount =
typeof tickCount === 'number' && Number.isFinite(tickCount)
? Math.max(2, Math.floor(tickCount))
: 10
const scale = scaleLinear(domain, [1, 0]) const scale = scaleLinear(domain, [1, 0])
if (nice) scale.nice() if (nice) scale.nice(effectiveTickCount)
const [axisMin, axisMax] = scale.domain() const [axisMin, axisMax] = scale.domain()
const values = tickValues ?? Array.from(new Set([axisMin, ...scale.ticks(10), axisMax])) const values = tickValues ?? scale.ticks(effectiveTickCount)
const topTickValue = Math.max(...values) const topTickValue = Math.max(...values)
const maximumTickCharacters = Math.max( const maximumTickCharacters = Math.max(
1, 1,
...values.map( ...values.map(
(value) => formatScientificAxisLabel(value, { axisMin, axisMax, topTickValue }).length, (value) => formatScientificAxisLabel(value, { axisMin, axisMax, topTickValue, unit }).length,
), ),
) )
return { return {
@@ -120,9 +133,10 @@ export function axisTextMetrics(
} }
} }
export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number { export function measureYAxisGroupClearance(group: YAxisSeriesGroup, tickCount?: number): number {
return ( return (
axisTextMetrics(group.domain, !group.fixed).tickTextWidth + axisTextMetrics(group.domain, true, undefined, group.seriesList[0]?.unit, tickCount)
.tickTextWidth +
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 +
@@ -130,9 +144,10 @@ export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
) )
} }
function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number { function measureYAxisGroupTickClearance(group: YAxisSeriesGroup, tickCount?: number): number {
return ( return (
axisTextMetrics(group.domain, !group.fixed).tickTextWidth + axisTextMetrics(group.domain, true, undefined, group.seriesList[0]?.unit, tickCount)
.tickTextWidth +
Y_AXIS_TICK_PADDING + Y_AXIS_TICK_PADDING +
Y_AXIS_OUTER_PADDING Y_AXIS_OUTER_PADDING
) )
@@ -143,13 +158,14 @@ export function measureTrackYAxisClearance(
overlayMode: WaveformOverlayMode, overlayMode: WaveformOverlayMode,
yDomain?: WaveformYDomain, yDomain?: WaveformYDomain,
yDomains?: Record<string, WaveformYDomain>, yDomains?: Record<string, WaveformYDomain>,
tickCount?: number,
): { left: number; right: number } { ): { left: number; right: number } {
return resolveYAxisSeriesGroups(track, overlayMode, yDomain, yDomains).reduce( return resolveYAxisSeriesGroups(track, overlayMode, yDomain, yDomains).reduce(
(clearance, group) => { (clearance, group) => {
clearance[group.side] += clearance[group.side] +=
overlayMode === 'multi-axis' || track.visibleSeries.length === 1 overlayMode === 'multi-axis' || track.visibleSeries.length === 1
? measureYAxisGroupClearance(group) ? measureYAxisGroupClearance(group, tickCount)
: measureYAxisGroupTickClearance(group) : measureYAxisGroupTickClearance(group, tickCount)
return clearance return clearance
}, },
{ left: 0, right: 0 }, { left: 0, right: 0 },

View File

@@ -25,7 +25,7 @@ import {
type GridCellGeometry, type GridCellGeometry,
type NormalizedWaveformGridOptions, type NormalizedWaveformGridOptions,
} from './grid' } from './grid'
import { axisTextMetrics, resolveYAxisSeriesGroups } from './layout' import { axisTextMetrics, resolveYAxisSeriesGroups, resolveYAxisTickCount } from './layout'
import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types' import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
import { applyXDomainStrategy } from './xDomain' import { applyXDomainStrategy } from './xDomain'
import { import {
@@ -54,6 +54,7 @@ export interface BuildTrackLayoutsOptions {
yDomains?: Record<string, [number, number]> yDomains?: Record<string, [number, number]>
timeUnit: 's' | 'ms' timeUnit: 's' | 'ms'
xAxisLabelFormatter?: WaveformXAxisLabelFormatter xAxisLabelFormatter?: WaveformXAxisLabelFormatter
yAxisSplitNumber?: number
rendering: ResolvedWaveformRenderingOptions rendering: ResolvedWaveformRenderingOptions
hideSecondaryLabels: boolean hideSecondaryLabels: boolean
yAxisLabelX: number yAxisLabelX: number
@@ -124,18 +125,27 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
) )
const sideOffsets = { left: 0, right: 0 } const sideOffsets = { left: 0, right: 0 }
const yAxes: WaveformYAxisLayout[] = yAxisGroups.map((group) => { const yAxes: WaveformYAxisLayout[] = yAxisGroups.map((group) => {
const tickCount = resolveYAxisTickCount(cell.plotHeight, options.yAxisSplitNumber)
const niceCount = Math.max(1, tickCount - 1)
const scale = scaleLinear(group.domain, [cell.plotHeight, 0]) const scale = scaleLinear(group.domain, [cell.plotHeight, 0])
if (!group.fixed) scale.nice() scale.nice(niceCount)
const majorTicks = scale.ticks(Math.max(2, Math.floor(cell.plotHeight / 55)))
const [axisStart, axisEnd] = scale.domain() const [axisStart, axisEnd] = scale.domain()
const majorTicks = Array.from(
{ length: tickCount },
(_, index) => axisStart + ((axisEnd - axisStart) * index) / (tickCount - 1),
)
const showAxisEnd = options.displayMode !== 'compact' || cell.row === 0 const showAxisEnd = options.displayMode !== 'compact' || cell.row === 0
const visibleMajorTicks = showAxisEnd const visibleMajorTicks = showAxisEnd
? majorTicks ? majorTicks
: majorTicks.filter((tick) => tick !== axisEnd) : majorTicks.filter((tick) => tick !== axisEnd)
const tickValues = Array.from( const tickValues = visibleMajorTicks
new Set([axisStart, ...visibleMajorTicks, ...(showAxisEnd ? [axisEnd] : [])]), const { tickTextWidth } = axisTextMetrics(
scale.domain() as [number, number],
false,
tickValues,
group.seriesList[0]?.unit,
tickCount,
) )
const { tickTextWidth } = axisTextMetrics(group.domain, !group.fixed, tickValues)
const clearance = const clearance =
tickTextWidth + tickTextWidth +
Y_AXIS_TICK_PADDING + Y_AXIS_TICK_PADDING +

View File

@@ -259,6 +259,9 @@ export function useWaveformChartController(
trackLayouts, trackLayouts,
innerWidth, innerWidth,
innerHeight, innerHeight,
sharedZoomDomain,
initialXDomain,
sharedTransform,
activeInteractionMode, activeInteractionMode,
hiddenSeriesIdSet, hiddenSeriesIdSet,
internalHiddenSeriesIds, internalHiddenSeriesIds,

View File

@@ -39,6 +39,9 @@ interface LifecycleContext {
trackLayouts: ComputedRef<TrackLayout[]> trackLayouts: ComputedRef<TrackLayout[]>
innerWidth: ComputedRef<number> innerWidth: ComputedRef<number>
innerHeight: ComputedRef<number> innerHeight: ComputedRef<number>
sharedZoomDomain: ComputedRef<[number, number]>
initialXDomain: ComputedRef<[number, number]>
sharedTransform: ShallowRef<ZoomTransform>
activeInteractionMode: ComputedRef<string | undefined> activeInteractionMode: ComputedRef<string | undefined>
hiddenSeriesIdSet: ComputedRef<Set<string>> hiddenSeriesIdSet: ComputedRef<Set<string>>
internalHiddenSeriesIds: Ref<Set<string>> internalHiddenSeriesIds: Ref<Set<string>>
@@ -93,6 +96,9 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
trackLayouts, trackLayouts,
innerWidth, innerWidth,
innerHeight, innerHeight,
sharedZoomDomain,
initialXDomain,
sharedTransform,
activeInteractionMode, activeInteractionMode,
hiddenSeriesIdSet, hiddenSeriesIdSet,
internalHiddenSeriesIds, internalHiddenSeriesIds,
@@ -144,25 +150,24 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
emit('page-change', nextPage, pageCount.value) emit('page-change', nextPage, pageCount.value)
} }
let pendingSharedXDomain: [number, number] | undefined
let pendingIndependentXDomains: Array<[number, number] | undefined> | undefined let pendingIndependentXDomains: Array<[number, number] | undefined> | undefined
function handleBeforeDataReferenceChange() { function handleBeforeDataReferenceChange() {
if (props.displayMode !== 'independent') return if (props.displayMode === 'independent') {
pendingIndependentXDomains = trackLayouts.value.map((track) => { pendingSharedXDomain = undefined
const configuredDomain = pendingIndependentXDomains = trackLayouts.value.map((track) => {
props.initialXDomains?.[track.series.trackId ?? track.series.id] ?? const current = track.xScale.domain() as [number, number]
props.initialXDomains?.[track.series.id] ?? const boundary = resolveInitialTrackDomain(track)
props.initialXDomain return current[1] - current[0] < boundary[1] - boundary[0] - 1e-12 ? current : undefined
if ( })
!configuredDomain || return
!Number.isFinite(configuredDomain[0]) || }
!Number.isFinite(configuredDomain[1]) || pendingIndependentXDomains = undefined
configuredDomain[0] === configuredDomain[1] const current = sharedZoomDomain.value
) { const boundary = initialXDomain.value
return undefined pendingSharedXDomain =
} current[1] - current[0] < boundary[1] - boundary[0] - 1e-12 ? [...current] : undefined
return track.xScale.domain() as [number, number]
})
} }
function handleDataReferenceChange() { function handleDataReferenceChange() {
@@ -186,10 +191,15 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
}) })
independentTransforms.value = nextTransforms independentTransforms.value = nextTransforms
pendingIndependentXDomains = undefined pendingIndependentXDomains = undefined
void nextTick(configureZoom) } else if (props.displayMode !== 'independent' && pendingSharedXDomain) {
return const boundary = initialXDomain.value
const groups = trackLayouts.value
.filter((track) => track.hasVisibleSeries)
.map((track) => track.seriesList)
const domain = constrainZoomDomain(pendingSharedXDomain, boundary, groups, props)
sharedTransform.value = transformForDomain(domain, boundary, innerWidth.value)
pendingSharedXDomain = undefined
} }
pendingIndependentXDomains = undefined
configureZoom() configureZoom()
}) })
} }

View File

@@ -112,7 +112,16 @@ 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) => axisTextMetrics(group.domain, !group.fixed).tickTextWidth) .map(
(group) =>
axisTextMetrics(
group.domain,
true,
undefined,
group.seriesList[0]?.unit,
props.axes?.y?.splitNumber,
).tickTextWidth,
)
const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText) const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText)
const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING
const labelCenterX = -( const labelCenterX = -(
@@ -157,6 +166,7 @@ export function useWaveformLayout(context: LayoutContext) {
props.overlayMode, props.overlayMode,
props.yDomain, props.yDomain,
props.yDomains, props.yDomains,
props.axes?.y?.splitNumber,
) )
return { return {
left: Math.max(maximum.left, clearance.left), left: Math.max(maximum.left, clearance.left),
@@ -298,6 +308,7 @@ export function useWaveformLayout(context: LayoutContext) {
: sharedYDomains.value, : sharedYDomains.value,
timeUnit: props.timeUnit, timeUnit: props.timeUnit,
xAxisLabelFormatter: props.axes?.x?.labelFormatter, xAxisLabelFormatter: props.axes?.x?.labelFormatter,
yAxisSplitNumber: props.axes?.y?.splitNumber,
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,

View File

@@ -11,6 +11,7 @@ import type {
import type { AnnotationSeriesCandidate } from '../annotation' import type { AnnotationSeriesCandidate } from '../annotation'
import { tryReleasePointerCapture } from './pointerCapture' import { tryReleasePointerCapture } from './pointerCapture'
import { transitionViewportInteraction } from './viewportInteractionState' import { transitionViewportInteraction } from './viewportInteractionState'
import { createViewportDomainSetter } from './viewportDomain'
import { constrainZoomDomain, transformForDomain } from './zoomConstraints' import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
interface ViewportContext { interface ViewportContext {
props: ResolvedWaveformChartProps props: ResolvedWaveformChartProps
@@ -344,6 +345,20 @@ export function useWaveformViewport(context: ViewportContext) {
editorSeriesOptions.value = [] editorSeriesOptions.value = []
void nextTick(configureZoom) void nextTick(configureZoom)
} }
const setViewportDomain = createViewportDomainSetter({
props,
trackLayouts,
initialXDomain,
resolveInitialTrackDomain,
innerWidth,
sharedTransform,
independentTransforms,
cancelPendingZoom,
cancelViewportDrag,
clearHover,
editorSeriesOptions,
configureZoom,
})
const requestViewportReset = (event: MouseEvent) => { const requestViewportReset = (event: MouseEvent) => {
if (isPresentationMode.value || !props.zoomable || !isZoomMode.value) return if (isPresentationMode.value || !props.zoomable || !isZoomMode.value) return
if (props.displayMode === 'independent') { if (props.displayMode === 'independent') {
@@ -372,6 +387,7 @@ export function useWaveformViewport(context: ViewportContext) {
finishViewportDrag, finishViewportDrag,
cancelViewportDrag, cancelViewportDrag,
resetViewport, resetViewport,
setViewportDomain,
requestViewportReset, requestViewportReset,
} }
} }

View File

@@ -0,0 +1,60 @@
import { nextTick, type ComputedRef, type Ref, type ShallowRef } from 'vue'
import type { ZoomTransform } from 'd3'
import type { TrackLayout } from '../core/types'
import type { ResolvedWaveformChartProps } from '../core/waveformChartTypes'
import type { AnnotationSeriesCandidate } from '../annotation'
import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
interface ViewportDomainContext {
props: ResolvedWaveformChartProps
trackLayouts: ComputedRef<TrackLayout[]>
initialXDomain: ComputedRef<[number, number]>
resolveInitialTrackDomain: (track: TrackLayout) => [number, number]
innerWidth: ComputedRef<number>
sharedTransform: ShallowRef<ZoomTransform>
independentTransforms: ShallowRef<ZoomTransform[]>
cancelPendingZoom: () => void
cancelViewportDrag: () => void
clearHover: () => void
editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
configureZoom: () => void
}
export function createViewportDomainSetter(context: ViewportDomainContext) {
return (domain: [number, number], trackIndex?: number) => {
if (!Number.isFinite(domain[0]) || !Number.isFinite(domain[1]) || domain[0] === domain[1])
return
context.cancelPendingZoom()
context.cancelViewportDrag()
if (context.props.displayMode === 'independent') {
const indexes =
trackIndex === undefined
? context.trackLayouts.value.map((track) => track.index)
: [trackIndex]
const nextTransforms = [...context.independentTransforms.value]
indexes.forEach((index) => {
const track = context.trackLayouts.value.find((item) => item.index === index)
if (!track) return
const boundary = context.resolveInitialTrackDomain(track)
const constrained = constrainZoomDomain(domain, boundary, [track.seriesList], context.props)
nextTransforms[index] = transformForDomain(constrained, boundary, track.width)
})
context.independentTransforms.value = nextTransforms
} else {
const boundary = context.initialXDomain.value
const groups = context.trackLayouts.value
.filter((track) => track.hasVisibleSeries)
.map((track) => track.seriesList)
const constrained = constrainZoomDomain(domain, boundary, groups, context.props)
context.sharedTransform.value = transformForDomain(
constrained,
boundary,
context.innerWidth.value,
)
}
context.clearHover()
context.editorSeriesOptions.value = []
void nextTick(context.configureZoom)
}
}

View File

@@ -44,9 +44,10 @@ function renderAxes() {
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 topTickValue = Math.max(...axis.tickValues)
const unit = axis.seriesList[0]?.unit
const yAxis = (axis.side === 'left' ? axisLeft(axis.scale) : axisRight(axis.scale)) const yAxis = (axis.side === 'left' ? axisLeft(axis.scale) : axisRight(axis.scale))
.tickFormat((value) => .tickFormat((value) =>
formatScientificAxisLabel(Number(value), { axisMin, axisMax, topTickValue }), formatScientificAxisLabel(Number(value), { axisMin, axisMax, topTickValue, unit }),
) )
.tickSize(-4) .tickSize(-4)
.tickPadding(7) .tickPadding(7)

View File

@@ -65,9 +65,9 @@ describe('WaveformChart', () => {
) )
expect(endTicks[0]).toHaveLength(1) expect(endTicks[0]).toHaveLength(1)
expect(Number(endTicks[0][0].text())).toBe(0.9) expect(Number(endTicks[0][0].text())).toBe(1)
expect(endTicks[1]).toHaveLength(1) expect(endTicks[1]).toHaveLength(1)
expect(Number(endTicks[1][0].text())).toBe(1.9) expect(Number(endTicks[1][0].text())).toBe(2)
expect(endTicks[2]).toHaveLength(0) expect(endTicks[2]).toHaveLength(0)
expect(endTicks[3]).toHaveLength(0) expect(endTicks[3]).toHaveLength(0)
}) })

View File

@@ -158,6 +158,12 @@ describe('WaveformChart', () => {
expect(paths[0].attributes('data-series-name')).toBe('BT2_2M') expect(paths[0].attributes('data-series-name')).toBe('BT2_2M')
const yAxisLabels = wrapper.findAll('.waveform-chart__y-axis-label') const yAxisLabels = wrapper.findAll('.waveform-chart__y-axis-label')
expect(yAxisLabels.map((label) => label.text())).toEqual(['BT2_2M', 'BT1_2M']) expect(yAxisLabels.map((label) => label.text())).toEqual(['BT2_2M', 'BT1_2M'])
expect(
wrapper
.findAll('.waveform-chart__axis--y .tick text')
.map((label) => label.text())
.some((label) => label.includes('(T)')),
).toBe(true)
expect(yAxisLabels.map((label) => label.attributes('fill'))).toEqual(['#0960bd', '#ff7f0e']) expect(yAxisLabels.map((label) => label.attributes('fill'))).toEqual(['#0960bd', '#ff7f0e'])
const labelX = Number( const labelX = Number(
yAxisLabels[0].attributes('transform')?.match(/^translate\(([-\d.]+),/)?.[1], yAxisLabels[0].attributes('transform')?.match(/^translate\(([-\d.]+),/)?.[1],
@@ -255,7 +261,7 @@ describe('WaveformChart', () => {
}) })
expect(startTicks).toHaveLength(1) expect(startTicks).toHaveLength(1)
expect(Number(startTicks[0].text())).toBe(0.1) expect(Number(startTicks[0].text())).toBe(0)
}, },
) )
@@ -291,7 +297,7 @@ describe('WaveformChart', () => {
{ displayMode }, { displayMode },
) )
const expectedEndValues = [0.9, 4.9] const expectedEndValues = [1, 5]
wrapper.findAll('.waveform-chart__track').forEach((track, index) => { wrapper.findAll('.waveform-chart__track').forEach((track, index) => {
const endTicks = track.findAll('.waveform-chart__axis--y .tick').filter((tick) => { const endTicks = track.findAll('.waveform-chart__axis--y .tick').filter((tick) => {
const match = tick.attributes('transform')?.match(/translate\(0,\s*([\d.]+)\)/) const match = tick.attributes('transform')?.match(/translate\(0,\s*([\d.]+)\)/)

View File

@@ -6,6 +6,26 @@ import { resizeObservers } from '../../test/setup'
import { gridSeries, mountSizedChart } from '../../test/waveformChart' import { gridSeries, mountSizedChart } from '../../test/waveformChart'
describe('WaveformChart', () => { describe('WaveformChart', () => {
it('applies the configured Y-axis split number', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 3 },
{ x: 1, y: 97 },
],
},
{ axes: { y: { splitNumber: 5 } } },
)
expect(
wrapper
.get('.waveform-chart__axis--y')
.findAll('.tick text')
.map((tick) => tick.text()),
).toEqual(['0', '25', '50', '75', '100'])
})
it('expands the Y-axis label gutter for signed values and long exponents', async () => { it('expands the Y-axis label gutter for signed values and long exponents', async () => {
const wrapper = await mountSizedChart( const wrapper = await mountSizedChart(
{ {
@@ -296,7 +316,7 @@ describe('WaveformChart', () => {
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000') 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 nice fixed Y-domain props and returns to automatic bounds', async () => {
const wrapper = await mountSizedChart({ const wrapper = await mountSizedChart({
kind: 'points', kind: 'points',
points: [ points: [
@@ -312,8 +332,8 @@ describe('WaveformChart', () => {
await wrapper.setProps({ yDomain: [3, 97] }) await wrapper.setProps({ yDomain: [3, 97] })
await flushPromises() await flushPromises()
expect(yTickLabels()).toContain('3') expect(yTickLabels()).toContain('0')
expect(yTickLabels()).toContain('97') expect(yTickLabels()).toContain('100')
await wrapper.setProps({ yDomain: undefined }) await wrapper.setProps({ yDomain: undefined })
await flushPromises() await flushPromises()

View File

@@ -16,13 +16,30 @@ function annotationEditorExists() {
} }
describe('WaveformChart', () => { describe('WaveformChart', () => {
it('shows legends when a track contains at least two series', async () => {
const oneSeries = visibilitySeries()
if (oneSeries.kind === 'series') oneSeries.series = oneSeries.series.slice(0, 1)
const withoutLegend = await mountSizedChart(oneSeries, {
grid: { rowCount: 1, columnCount: 1 },
})
expect(withoutLegend.findAll('.waveform-chart__legend')).toHaveLength(0)
const twoSeries = visibilitySeries()
if (twoSeries.kind === 'series') twoSeries.series = twoSeries.series.slice(0, 2)
const withLegend = await mountSizedChart(twoSeries, {
grid: { rowCount: 1, columnCount: 1 },
})
expect(withLegend.findAll('.waveform-chart__legend')).toHaveLength(1)
expect(withLegend.find('.waveform-chart__legend').attributes('data-position')).toBe('top-right')
})
it('resolves legend positions by stable track id across pages', async () => { it('resolves legend positions by stable track id across pages', async () => {
const wrapper = await mountSizedChart( const wrapper = await mountSizedChart(
{ {
kind: 'series', kind: 'series',
series: Array.from({ length: 8 }, (_, index) => ({ series: Array.from({ length: 12 }, (_, index) => ({
id: `series-${index}`, id: `series-${index}`,
trackId: `frame-${Math.floor(index / 2)}`, trackId: `frame-${Math.floor(index / 3)}`,
name: `series ${index}`, name: `series ${index}`,
data: { data: {
kind: 'points' as const, kind: 'points' as const,
@@ -75,9 +92,9 @@ describe('WaveformChart', () => {
const wrapper = await mountSizedChart( const wrapper = await mountSizedChart(
{ {
kind: 'series', kind: 'series',
series: Array.from({ length: 4 }, (_, index) => ({ series: Array.from({ length: 6 }, (_, index) => ({
id: `series-${index}`, id: `series-${index}`,
trackId: `frame-${Math.floor(index / 2)}`, trackId: `frame-${Math.floor(index / 3)}`,
name: `series ${index}`, name: `series ${index}`,
data: { data: {
kind: 'points', kind: 'points',
@@ -112,7 +129,7 @@ describe('WaveformChart', () => {
}) })
const items = wrapper.findAll('.waveform-chart__legend-item') const items = wrapper.findAll('.waveform-chart__legend-item')
expect(items).toHaveLength(2) expect(items).toHaveLength(3)
expect(items.every((item) => item.attributes('disabled') !== undefined)).toBe(true) expect(items.every((item) => item.attributes('disabled') !== undefined)).toBe(true)
expect(wrapper.get('.waveform-legend__panel').classes()).not.toContain( expect(wrapper.get('.waveform-legend__panel').classes()).not.toContain(
'waveform-legend__panel--interactive', 'waveform-legend__panel--interactive',
@@ -128,7 +145,7 @@ describe('WaveformChart', () => {
overlayMode: 'multi-axis', overlayMode: 'multi-axis',
}) })
expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(2) expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(3)
expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true) expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true)
const annotationLayer = wrapper.get('.waveform-annotation-layer').element const annotationLayer = wrapper.get('.waveform-annotation-layer').element
const legendLayer = wrapper.get('.waveform-chart__legend-layer').element const legendLayer = wrapper.get('.waveform-chart__legend-layer').element
@@ -143,8 +160,8 @@ describe('WaveformChart', () => {
expect( expect(
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['low']) ).toEqual(['low', 'mid'])
expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(1) expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(2)
expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(false) expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(false)
expect(wrapper.findAll('.waveform-chart__legend-item')[1].classes()).toContain('is-hidden') expect(wrapper.findAll('.waveform-chart__legend-item')[1].classes()).toContain('is-hidden')
expect(wrapper.findAll('.waveform-chart__legend-item')[1].attributes('aria-pressed')).toBe( expect(wrapper.findAll('.waveform-chart__legend-item')[1].attributes('aria-pressed')).toBe(
@@ -170,12 +187,12 @@ describe('WaveformChart', () => {
) )
flushAnimationFrames() flushAnimationFrames()
await flushPromises() await flushPromises()
expect(wrapper.findAll('.waveform-chart__tooltip-series')).toHaveLength(1) expect(wrapper.findAll('.waveform-chart__tooltip-series')).toHaveLength(2)
expect(wrapper.get('.waveform-chart__tooltip-series').text()).toContain('低量程') expect(wrapper.get('.waveform-chart__tooltip-series').text()).toContain('低量程')
await wrapper.findAll('.waveform-chart__legend-item')[1].trigger('click') await wrapper.findAll('.waveform-chart__legend-item')[1].trigger('click')
await flushPromises() await flushPromises()
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(2) expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(3)
expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true) expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true)
expect(wrapper.emitted('series-visibility-change')?.at(-1)).toEqual([ expect(wrapper.emitted('series-visibility-change')?.at(-1)).toEqual([
{ seriesId: 'high', visible: true, hiddenSeriesIds: [] }, { seriesId: 'high', visible: true, hiddenSeriesIds: [] },
@@ -191,20 +208,20 @@ describe('WaveformChart', () => {
expect( expect(
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['low']) ).toEqual(['low', 'mid'])
await wrapper.findAll('.waveform-chart__legend-item')[0].trigger('click') await wrapper.findAll('.waveform-chart__legend-item')[0].trigger('click')
expect(wrapper.emitted('update:hidden-series-ids')?.at(-1)).toEqual([ expect(wrapper.emitted('update:hidden-series-ids')?.at(-1)).toEqual([
['high', 'temporarily-absent', 'low'], ['high', 'temporarily-absent', 'low'],
]) ])
expect( expect(
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['low']) ).toEqual(['low', 'mid'])
await wrapper.setProps({ hiddenSeriesIds: ['low'] }) await wrapper.setProps({ hiddenSeriesIds: ['low'] })
await flushPromises() await flushPromises()
expect( expect(
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['high']) ).toEqual(['high', 'mid'])
}) })
it('retains uncontrolled visibility by stable ID and clears removed IDs', async () => { it('retains uncontrolled visibility by stable ID and clears removed IDs', async () => {
@@ -223,7 +240,7 @@ describe('WaveformChart', () => {
await flushPromises() await flushPromises()
expect( expect(
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['low']) ).toEqual(['mid', 'low'])
await wrapper.setProps({ await wrapper.setProps({
data: { data: {
@@ -234,12 +251,12 @@ describe('WaveformChart', () => {
await flushPromises() await flushPromises()
await wrapper.setProps({ data: original }) await wrapper.setProps({ data: original })
await flushPromises() await flushPromises()
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(2) expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(3)
}) })
it('keeps a recoverable legend and stops chart interaction when every series is hidden', async () => { it('keeps a recoverable legend and stops chart interaction when every series is hidden', async () => {
const wrapper = await mountSizedChart(visibilitySeries(), { const wrapper = await mountSizedChart(visibilitySeries(), {
defaultHiddenSeriesIds: ['low', 'high'], defaultHiddenSeriesIds: ['low', 'high', 'mid'],
grid: { rowCount: 1, columnCount: 1 }, grid: { rowCount: 1, columnCount: 1 },
legend: { interactive: true }, legend: { interactive: true },
overlayMode: 'multi-axis', overlayMode: 'multi-axis',
@@ -248,7 +265,7 @@ describe('WaveformChart', () => {
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(0) expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(0)
expect(wrapper.findAll('.waveform-chart__axis')).toHaveLength(0) expect(wrapper.findAll('.waveform-chart__axis')).toHaveLength(0)
expect(wrapper.findAll('.waveform-chart__overlay')).toHaveLength(0) expect(wrapper.findAll('.waveform-chart__overlay')).toHaveLength(0)
expect(wrapper.findAll('.waveform-chart__legend-item')).toHaveLength(2) expect(wrapper.findAll('.waveform-chart__legend-item')).toHaveLength(3)
expect(wrapper.get('.waveform-track__no-visible-series').text()).toBe('暂无可见曲线') expect(wrapper.get('.waveform-track__no-visible-series').text()).toBe('暂无可见曲线')
await wrapper.findAll('.waveform-chart__legend-item')[0].trigger('click') await wrapper.findAll('.waveform-chart__legend-item')[0].trigger('click')

View File

@@ -15,6 +15,7 @@ describe('WaveformChart', () => {
{ {
trackId: 'shared-frame', trackId: 'shared-frame',
name: '普通量程', name: '普通量程',
unit: 'V',
data: { data: {
kind: 'points', kind: 'points',
points: [ points: [
@@ -26,6 +27,7 @@ describe('WaveformChart', () => {
{ {
trackId: 'shared-frame', trackId: 'shared-frame',
name: '大量程', name: '大量程',
unit: 'A',
data: { data: {
kind: 'points', kind: 'points',
points: [ points: [
@@ -37,6 +39,7 @@ describe('WaveformChart', () => {
{ {
trackId: 'shared-frame', trackId: 'shared-frame',
name: '小量程', name: '小量程',
unit: 'T',
data: { data: {
kind: 'points', kind: 'points',
points: [ points: [
@@ -57,7 +60,11 @@ describe('WaveformChart', () => {
.map((label) => label.text()) .map((label) => label.text())
.filter((label) => label.startsWith('E')), .filter((label) => label.startsWith('E')),
), ),
).toEqual([[], [expect.stringMatching(/^E\+03 /)], [expect.stringMatching(/^E-04 /)]]) ).toEqual([
[],
[expect.stringMatching(/^E\+03 \(A\) /)],
[expect.stringMatching(/^E-04 \(T\) /)],
])
}) })
it('reprojects annotations with the Y axis assigned to their series', async () => { it('reprojects annotations with the Y axis assigned to their series', async () => {
@@ -177,6 +184,18 @@ describe('WaveformChart', () => {
], ],
}, },
}, },
{
id: 'reference',
trackId: 'frame-1',
name: 'REF_CH_1',
data: {
kind: 'points',
points: [
{ x: 0, y: 0.25 },
{ x: 1, y: 1.25 },
],
},
},
], ],
}, },
{ frameNumber: 1, grid: { rowCount: 2, columnCount: 1 } }, { frameNumber: 1, grid: { rowCount: 2, columnCount: 1 } },
@@ -186,7 +205,7 @@ describe('WaveformChart', () => {
expect(tracks).toHaveLength(2) expect(tracks).toHaveLength(2)
expect( expect(
tracks[0].findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), tracks[0].findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['primary', 'comparison']) ).toEqual(['primary', 'comparison', 'reference'])
expect( expect(
tracks[1].findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), tracks[1].findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['second-frame']) ).toEqual(['second-frame'])
@@ -203,12 +222,13 @@ describe('WaveformChart', () => {
expect(legend.findAll('.waveform-chart__legend-item').map((item) => item.text())).toEqual([ expect(legend.findAll('.waveform-chart__legend-item').map((item) => item.text())).toEqual([
'BT2_2M', 'BT2_2M',
'TEST_CH_1', 'TEST_CH_1',
'REF_CH_1',
]) ])
expect( expect(
legend legend
.findAll('.waveform-legend__swatch') .findAll('.waveform-legend__swatch')
.map((swatch) => swatch.get('path').attributes('stroke')), .map((swatch) => swatch.get('path').attributes('stroke')),
).toEqual(['#0960bd', '#2ca02c']) ).toEqual(['#0960bd', '#2ca02c', '#d62728'])
expect(wrapper.findAll('.waveform-chart__watermark').map((item) => item.text())).toEqual([ expect(wrapper.findAll('.waveform-chart__watermark').map((item) => item.text())).toEqual([
'1', '1',
'2', '2',
@@ -232,10 +252,11 @@ describe('WaveformChart', () => {
await flushPromises() await flushPromises()
const tooltipSeries = wrapper.findAll('.waveform-chart__tooltip-series') const tooltipSeries = wrapper.findAll('.waveform-chart__tooltip-series')
expect(tooltipSeries).toHaveLength(2) expect(tooltipSeries).toHaveLength(3)
expect(tooltipSeries.map((item) => item.text())).toEqual([ expect(tooltipSeries.map((item) => item.text())).toEqual([
expect.stringContaining('BT2_2M'), expect.stringContaining('BT2_2M'),
expect.stringContaining('TEST_CH_1'), expect.stringContaining('TEST_CH_1'),
expect.stringContaining('REF_CH_1'),
]) ])
}) })
@@ -268,6 +289,18 @@ describe('WaveformChart', () => {
], ],
}, },
}, },
{
id: 'third',
trackId: 'shared',
name: 'third',
data: {
kind: 'points',
points: [
{ x: 0, y: 2 },
{ x: 1, y: 3 },
],
},
},
], ],
}, },
{ grid: { rowCount: 1, columnCount: 1 } }, { grid: { rowCount: 1, columnCount: 1 } },

View File

@@ -42,6 +42,19 @@ const presentationData: WaveformData = {
], ],
}, },
}, },
{
id: 'mid',
trackId: 'shared',
name: '中量程',
data: {
kind: 'points',
points: [
{ x: 0, y: 50 },
{ x: 1, y: 100 },
{ x: 2, y: 75 },
],
},
},
{ {
id: 'other', id: 'other',
name: '其他通道', name: '其他通道',
@@ -150,9 +163,9 @@ describe('WaveformChart presentation mode', () => {
cancelable: true, cancelable: true,
}), }),
) )
wrapper.get('.waveform-chart__svg').element.dispatchEvent( wrapper
new MouseEvent('dblclick', { bubbles: true, cancelable: true }), .get('.waveform-chart__svg')
) .element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
await wrapper.get('[data-annotation-id="note"]').trigger('contextmenu', { await wrapper.get('[data-annotation-id="note"]').trigger('contextmenu', {
clientX: width / 2, clientX: width / 2,
clientY: height / 2, clientY: height / 2,

View File

@@ -119,6 +119,108 @@ describe('WaveformChart viewport reset', () => {
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1000') expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1000')
}) })
it('keeps a controlled shared viewport when the data reference changes', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
],
},
{ displayMode: 'separated' },
)
const chart = wrapper.vm as unknown as {
setViewportDomain: (domain: [number, number], trackIndex?: number) => void
}
chart.setViewportDomain([0.25, 0.75])
await flushPromises()
const startBefore = Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())
const endBefore = Number(wrapper.get('.waveform-chart__axis-endpoint--end').text())
expect(startBefore).toBeGreaterThan(0)
expect(endBefore).toBeLessThan(1000)
await wrapper.setProps({
data: {
kind: 'points',
points: [
{ x: 0, y: 10 },
{ x: 1, y: 11 },
],
},
})
await flushPromises()
expect(Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())).toBeCloseTo(
startBefore,
10,
)
expect(Number(wrapper.get('.waveform-chart__axis-endpoint--end').text())).toBeCloseTo(
endBefore,
10,
)
})
it('keeps independently controlled track viewports when the data reference changes', async () => {
const wrapper = await mountSizedChart(gridSeries(2), {
displayMode: 'independent',
grid: { rowCount: 1, columnCount: 2 },
})
const chart = wrapper.vm as unknown as {
setViewportDomain: (domain: [number, number], trackIndex?: number) => void
}
chart.setViewportDomain([0.2, 0.8], 0)
chart.setViewportDomain([0.1, 0.9], 1)
await flushPromises()
const endpoints = () =>
wrapper.findAll('.waveform-chart__track').map((track) => ({
start: track.get('.waveform-chart__axis-endpoint--start').text(),
end: track.get('.waveform-chart__axis-endpoint--end').text(),
}))
const endpointsBefore = endpoints()
expect(Number(endpointsBefore[0].start)).toBeGreaterThan(0)
expect(Number(endpointsBefore[0].end)).toBeLessThan(1000)
expect(Number(endpointsBefore[1].start)).toBeGreaterThan(0)
expect(Number(endpointsBefore[1].end)).toBeLessThan(1000)
await wrapper.setProps({ data: gridSeries(2) })
await flushPromises()
const endpointsAfter = endpoints()
endpointsAfter.forEach((endpoint, index) => {
expect(Number(endpoint.start)).toBeCloseTo(Number(endpointsBefore[index].start), 10)
expect(Number(endpoint.end)).toBeCloseTo(Number(endpointsBefore[index].end), 10)
})
})
it('ignores invalid domains and applies viewport constraints', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
],
},
{ displayMode: 'separated', minZoomSpan: 0.2 },
)
const chart = wrapper.vm as unknown as {
setViewportDomain: (domain: [number, number], trackIndex?: number) => void
}
chart.setViewportDomain([Number.NaN, 0.5])
await flushPromises()
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
chart.setViewportDomain([0.49, 0.51])
await flushPromises()
const start = Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())
const end = Number(wrapper.get('.waveform-chart__axis-endpoint--end').text())
expect(end - start).toBeGreaterThanOrEqual(200)
})
it('resets shared and independent explicit domains to their included nice bounds', async () => { it('resets shared and independent explicit domains to their included nice bounds', async () => {
const shared = await mountSizedChart( const shared = await mountSizedChart(
{ {

View File

@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import { createSimulatedWaveformData } from './simulatedWaveforms' import { createSimulatedWaveformData } from './simulatedWaveforms'
describe('simulated waveform data', () => { describe('simulated waveform data', () => {
it('creates deterministic, finite six-channel data', () => { it('creates deterministic, finite seven-channel data with a two-series second frame', () => {
const first = createSimulatedWaveformData() const first = createSimulatedWaveformData()
const second = createSimulatedWaveformData() const second = createSimulatedWaveformData()
@@ -11,8 +11,12 @@ describe('simulated waveform data', () => {
expect(first.kind).toBe('series') expect(first.kind).toBe('series')
if (first.kind !== 'series') return if (first.kind !== 'series') return
expect(first.series).toHaveLength(6) expect(first.series).toHaveLength(7)
expect(new Set(first.series.map((series) => series.id)).size).toBe(6) expect(new Set(first.series.map((series) => series.id)).size).toBe(7)
const secondFrame = first.series.filter(
(series) => series.trackId === 'simulated-harmonic-frame',
)
expect(secondFrame).toHaveLength(2)
expect(new Set(first.series.map((series) => series.shotNo))).toEqual(new Set(['13300'])) expect(new Set(first.series.map((series) => series.shotNo))).toEqual(new Set(['13300']))
first.series.forEach((series) => { first.series.forEach((series) => {
expect(series.data.kind).toBe('points') expect(series.data.kind).toBe('points')

View File

@@ -13,7 +13,7 @@ type ErrorGenerator = (
interface SimulatedSeriesDefinition extends Pick< interface SimulatedSeriesDefinition extends Pick<
WaveformSeries, WaveformSeries,
'id' | 'shotNo' | 'name' | 'unit' | 'lineType' | 'pointType' | 'errorBar' 'id' | 'trackId' | 'shotNo' | 'name' | 'unit' | 'color' | 'lineType' | 'pointType' | 'errorBar'
> { > {
signal: SignalGenerator signal: SignalGenerator
errors?: ErrorGenerator errors?: ErrorGenerator
@@ -60,6 +60,7 @@ const seriesDefinitions: SimulatedSeriesDefinition[] = [
}, },
{ {
id: 'simulated-harmonic', id: 'simulated-harmonic',
trackId: 'simulated-harmonic-frame',
shotNo: '13300', shotNo: '13300',
name: '谐波扰动', name: '谐波扰动',
unit: 'V', unit: 'V',
@@ -68,6 +69,18 @@ const seriesDefinitions: SimulatedSeriesDefinition[] = [
signal: (time) => signal: (time) =>
0.9 * Math.sin(TWO_PI * 0.55 * time) + 0.28 * Math.sin(TWO_PI * 2.2 * time + 0.4), 0.9 * Math.sin(TWO_PI * 0.55 * time) + 0.28 * Math.sin(TWO_PI * 2.2 * time + 0.4),
}, },
{
id: 'simulated-harmonic-reference',
trackId: 'simulated-harmonic-frame',
shotNo: '13300',
name: '谐波对比',
unit: 'V',
color: '#2ca02c',
lineType: 'linear',
pointType: 'none',
signal: (time) =>
0.62 * Math.sin(TWO_PI * 0.55 * time + 0.55) + 0.18 * Math.sin(TWO_PI * 2.2 * time - 0.2),
},
{ {
id: 'simulated-damped', id: 'simulated-damped',
shotNo: '13300', shotNo: '13300',

View File

@@ -58,6 +58,18 @@ export function visibilitySeries(): WaveformData {
], ],
}, },
}, },
{
id: 'mid',
trackId: 'shared-frame',
name: '中量程',
data: {
kind: 'points',
points: [
{ x: 0, y: 100 },
{ x: 1, y: 200 },
],
},
},
], ],
} }
} }

View File

@@ -174,6 +174,8 @@ export interface WaveformAxesOptions {
} }
y?: { y?: {
lineVisible?: boolean lineVisible?: boolean
/** Target total number of equal Y-axis ticks, including endpoints. Defaults to 5. */
splitNumber?: number
} }
} }

View File

@@ -40,6 +40,15 @@ describe('waveform number formatters', () => {
expect(formatScientificAxisExponent(0.0001, 0.0003)).toBe('E-04') expect(formatScientificAxisExponent(0.0001, 0.0003)).toBe('E-04')
}) })
it('appends a unit to the top tick with or without scientific notation', () => {
expect(formatScientificAxisLabel(3000, { axisMin: 1000, axisMax: 3000, unit: 'V' })).toBe(
'E+03 (V) 3',
)
expect(formatScientificAxisLabel(3, { axisMin: 0, axisMax: 3, unit: 'A' })).toBe('(A) 3')
expect(formatScientificAxisLabel(2, { axisMin: 0, axisMax: 3, unit: 'A' })).toBe('2')
expect(formatScientificAxisLabel(3, { axisMin: 0, axisMax: 3, unit: ' ' })).toBe('3')
})
it('derives the exponent from the largest absolute endpoint', () => { it('derives the exponent from the largest absolute endpoint', () => {
expect(resolveScientificAxisExponent(-9000, -1000)).toBe(3) expect(resolveScientificAxisExponent(-9000, -1000)).toBe(3)
expect(resolveScientificAxisExponent(-100_000, -3000)).toBe(5) expect(resolveScientificAxisExponent(-100_000, -3000)).toBe(5)

View File

@@ -11,6 +11,7 @@ export interface ScientificAxisLabelOptions {
axisMin?: number axisMin?: number
axisMax?: number axisMax?: number
topTickValue?: number topTickValue?: number
unit?: string
} }
/** @deprecated Use ScientificAxisLabelOptions. */ /** @deprecated Use ScientificAxisLabelOptions. */
@@ -71,9 +72,11 @@ export function formatScientificAxisLabel(
const scaledValue = exponent === null ? value : value / 10 ** exponent const scaledValue = exponent === null ? value : value / 10 ** exponent
const formattedValue = formatYAxisNumber(scaledValue) const formattedValue = formatYAxisNumber(scaledValue)
const topTickValue = options.topTickValue ?? options.axisMax const topTickValue = options.topTickValue ?? options.axisMax
return exponent !== null && value === topTickValue if (value !== topTickValue) return formattedValue
? `${formatExponent(exponent)} ${formattedValue}` const unit = options.unit?.trim()
: formattedValue const unitLabel = unit ? `(${unit}) ` : ''
const exponentLabel = exponent === null ? '' : `${formatExponent(exponent)} `
return `${exponentLabel}${unitLabel}${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. */