6 Commits

Author SHA1 Message Date
李启源
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
李启源
f98c264562 fix(chart): support wheel zoom out
All checks were successful
Package component / package (push) Successful in 3m50s
2026-07-31 12:07:08 +08:00
李启源
979b698156 fix(chart): retain axes in clean view
All checks were successful
Package component / package (push) Successful in 3m58s
2026-07-31 10:27:27 +08:00
李启源
a12002ba11 feat(chart): support fixed y-axis domains
All checks were successful
Package component / package (push) Successful in 9m22s
2026-07-29 15:50:25 +08:00
李启源
6cbb1e7668 chore(release): bump version to 0.1.24
All checks were successful
Package component / package (push) Successful in 3m50s
2026-07-29 12:27:30 +08:00
48 changed files with 1786 additions and 332 deletions

136
README.md
View File

@@ -12,6 +12,9 @@
最新稳定版 Demo<https://lqycustomsite.online/waveform-analysis/>
本地运行 `pnpm dev` 后,可通过
<http://127.0.0.1:5173/#/fixed-y-domain> 查看固定振幅上下限示例。
## 特性
- Vue 3 Composition API + TypeScript支持按需导入 `WaveformChart`
@@ -22,6 +25,7 @@
- 缩放过程事件、缩放结束按可视区间加载和视口重置
- 可选的空格拖拽平移,默认关闭并隔离多图表实例
- 多系列图例、受控显隐、网格分页和最多四根 Y 轴
- 自动 Y 轴范围,以及全局、按轨道或按系列配置的固定振幅范围
- 按轨道控制水平/垂直网格线的显隐与颜色
- 受控标注、右键编辑、拖拽避让和自定义颜色
- 标题、图框、坐标轴、零值参考线、净图和渲染参数可配置
@@ -92,39 +96,41 @@ const data = ref<WaveformData>({
### Props
| Prop | 类型 | 默认值 | 说明 |
| --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | --------------------------------- |
| `data` | `WaveformData` | 必填 | 波形数据 |
| `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 |
| `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 |
| `timeUnit` | `'s' \| 'ms'` | `'ms'` | 坐标轴和 tooltip 展示单位 |
| `xLabel` / `yLabel` | `string` | `时间timeUnit` / `'幅值'` | 坐标轴名称 |
| `lineColor` | `string` | `'#0960bd'` | 单波形默认颜色 |
| `width` / `height` | `number` | 自适应 | 组件总尺寸,单位为 CSS 像素 |
| `zoomable` / `showTooltip` | `boolean` | `true` / `true` | 缩放和数值 tooltip 开关 |
| `pannable` | `boolean` | `false` | 空格拖拽平移开关 |
| `minZoomSpan` | `number` | 未设置 | 最小缩放跨度,使用原始 X 数据单位 |
| `minVisiblePoints` | `number` | `0` | 缩放后至少保留的不同 X 坐标数 |
| `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围 |
| `initialXDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置初始范围 |
| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 |
| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐 |
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
| `frameNumber` | `string \| number` | 未设置 | 图框水印内容 |
| `zeroLine` | `WaveformZeroLineOptions` | `{ visible: false }` | 零值参考线显隐与样式 |
| `cleanView` | `boolean` | `false` | 仅保留波形的净图模式 |
| `presentationMode` | `boolean` | `false` | 禁用绘图区交互的展示模式 |
| `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 |
| `annotationsVisible` | `boolean` | `true` | 标注图层显隐 |
| `interactionMode` | `'zoom' \| 'annotation'` | `'zoom'` | 左键交互模式 |
| `hiddenSeriesIds` | `string[]` | 未设置 | 受控隐藏系列 ID |
| `defaultHiddenSeriesIds` | `string[]` | `[]` | 非受控模式的初始隐藏系列 |
| Prop | 类型 | 默认值 | 说明 |
| --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | ---------------------------------- |
| `data` | `WaveformData` | 必填 | 波形数据 |
| `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 |
| `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 |
| `timeUnit` | `'s' \| 'ms'` | `'ms'` | 坐标轴和 tooltip 展示单位 |
| `xLabel` / `yLabel` | `string` | `时间timeUnit` / `'幅值'` | 坐标轴名称 |
| `lineColor` | `string` | `'#0960bd'` | 单波形默认颜色 |
| `width` / `height` | `number` | 自适应 | 组件总尺寸,单位为 CSS 像素 |
| `zoomable` / `showTooltip` | `boolean` | `true` / `true` | 缩放和数值 tooltip 开关 |
| `pannable` | `boolean` | `false` | 空格拖拽平移开关 |
| `minZoomSpan` | `number` | 未设置 | 最小缩放跨度,使用原始 X 数据单位 |
| `minVisiblePoints` | `number` | `0` | 缩放后至少保留的不同 X 坐标数 |
| `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围 |
| `initialXDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置初始范围 |
| `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 |
| `yDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置固定范围 |
| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 |
| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐与 X 轴 label 格式化 |
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
| `frameNumber` | `string \| number` | 未设置 | 图框水印内容 |
| `zeroLine` | `WaveformZeroLineOptions` | `{ visible: false }` | 零值参考线显隐与样式 |
| `cleanView` | `boolean` | `false` | 保留波形、图框和刻度的净图模式 |
| `presentationMode` | `boolean` | `false` | 禁用绘图区交互的展示模式 |
| `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 |
| `annotationsVisible` | `boolean` | `true` | 标注图层显隐 |
| `interactionMode` | `'zoom' \| 'annotation'` | `'zoom'` | 左键交互模式 |
| `hiddenSeriesIds` | `string[]` | 未设置 | 受控隐藏系列 ID |
| `defaultHiddenSeriesIds` | `string[]` | `[]` | 非受控模式的初始隐藏系列 |
所有公开类型均可从包入口导入,例如 `WaveformData``WaveformSeries`
`WaveformAnnotation``WaveformLineStyle``WaveformRenderingOptions`
`WaveformAxesOptions``WaveformZeroLineOptions``WaveformGridOptions`
`WaveformGridTrackLines`
`WaveformAxesOptions``WaveformXAxisLabelFormatter``WaveformZeroLineOptions`
`WaveformGridOptions``WaveformGridTrackLines`
### 数据结构
@@ -177,11 +183,43 @@ import 'waveform-analysis/style.css'
</template>
```
### 固定振幅上下限
未传入 Y 轴范围时,组件继续根据当前可见系列及其误差棒自动计算范围。传入 `yDomain`
后,所有波形使用同一个固定范围;超出范围的部分只在绘图区裁剪,不会过滤或修改原始数据:
```vue
<WaveformChart :data="chartData" :y-domain="[-80, 80]" />
```
多通道可以通过 `yDomains` 按稳定的 `trackId``seriesId` 分别配置:
```vue
<WaveformChart
:data="chartData"
:y-domain="[-100, 100]"
:y-domains="{
voltage: [-65, 65],
current: [-260, 260],
}"
/>
```
范围优先级为 `trackId` 配置、`seriesId` 配置、全局 `yDomain`、数据自动范围。上下限必须
是两个有限且不相等的数字;倒序范围会自动调整为升序,无效配置会回退到下一优先级。
固定范围会精确作为坐标域使用,不会经过 D3 的 `nice()` 扩展。
单值轴叠加模式会合并同一根轴上所有可见系列的有效范围;多值轴模式按系列分别使用配置,
超过四根轴后复用第 4 根轴的系列会取范围并集。隐藏系列不参与公共范围合并。
固定范围存在时,对应图框的 Y 轴不会被平移或视口重置覆盖X 轴缩放、平移和重置保持原有
行为。运行时更新或移除 `yDomain` / `yDomains` 会立即重新布局,移除后恢复自动范围。
### 缩放后按可视区间加载数据
组件支持 Plotly 风格的矩形框选缩放:在 zoom 模式下按住鼠标左键拖拽,松开后同时缩放
X/Y 轴;设置 `pannable` 后,指针位于图表内时按住空格键拖拽可平移当前视口。
鼠标滚轮可放大,双击恢复完整视口。
鼠标滚轮可放大和缩小,双击恢复完整视口。
组件会在滚轮或框选缩放结束后触发 `zoom-end`,调用方可以使用端点请求后端,再通过
`data` 传回新数据。独立分图模式还会包含 `trackIndex` 和稳定的 `seriesIds`
@@ -411,9 +449,10 @@ scale 定位零线:
`dash` 直接对应 SVG 的 `stroke-dasharray`;传入空字符串可显示实线。无效或非正数的
`width` 会回退到 `1`
设置 `cleanView` 后,组件隐藏标题内容、图例、网格、坐标轴、轴标签、图框背景与边框、帧水印
零值参考线、标注和分页器,同时保留原图的标题区域、边距和波形尺寸。缩放、悬浮、十字线和
tooltip 仍然可用,切换回普通模式后原有配置和标注不会丢失:
设置 `cleanView` 后,组件保留波形、图框边框、X/Y 轴刻度及刻度值,并隐藏标题内容、图例、
网格、轴标签、图框背景、帧水印、零值参考线、标注和分页器原图的标题区域、边距和波形
尺寸保持不变;缩放、悬浮、十字线和 tooltip 仍然可用,切换回普通模式后原有配置和标注
不会丢失:
```vue
<WaveformChart :data="chartData" :clean-view="cleanViewEnabled" />
@@ -469,6 +508,30 @@ tooltip 仍然可用,切换回普通模式后原有配置和标注不会丢失
/>
```
X 轴刻度和左右端点默认先按 `timeUnit` 转换为秒或毫秒,再显示为无千分位、无科学计数法的
完整普通十进制值,不会四舍五入为整数。可通过 `axes.x.labelFormatter` 对显示值做运算和格式化:
```vue
<WaveformChart
:data="chartData"
:axes="{
x: {
labelFormatter: (value, context) =>
`${context.kind === 'tick' ? '' : '[' + context.kind + '] '}${(value / 1000).toFixed(3)}`,
},
}"
/>
```
formatter 首参是已按 `timeUnit` 换算的数值;上下文包含 `kind``tick``start``end`)、
原始 `rawValue``timeUnit`、原始可视域 `domain` 和换算后的 `displayDomain`。formatter 只决定
label 文本,不改变刻度位置、源数据、缩放域或事件中的原始 X 坐标。
Demo 左侧“网格与轴线”支持直接切换数值或固定时间格式。数值模式可设置运算倍率和小数位数;
固定时间模式输出 `YYYY-MM-DD HH:mm:ss[.SSS]`,可选择中国标准时间、本地时区或 UTC并控制
是否显示毫秒。时间戳数据仍按组件的秒坐标契约传入,使用默认毫秒显示单位时 formatter 会收到
可直接传给 `Date` 的毫秒时间戳。
`interactionMode` 可选 `zoom``annotation`,默认使用缩放模式。右键绘图区可直接打开
标注编辑器,无需切换交互模式。`zoomable``pannable``showTooltip` 可分别控制缩放、
空格拖拽平移和 tooltip平移默认关闭。
@@ -564,7 +627,7 @@ async function importAnnotationFile(file: File) {
字段无效时会抛出 `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 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。
标注框默认布局在采样点正上方,只做绘图区边界裁剪;文本框通过连接箭头指向标注位置,多个标注重叠时可通过拖动手动避让。
## 事件
@@ -589,7 +652,8 @@ X、Y 轴会根据各自完整显示域选择格式:最大绝对值在 `[0.01,
- `src/index.ts`:组件库公开入口和工具函数导出
- `src/components/WaveformChart.vue`图表容器、缩放、tooltip、图例和标注编排
- `src/components/{core,data,rendering,interaction,annotation}`:数据、布局、渲染和交互模块
- `src/App.vue`:可交互 demo`src/data` 中提供示例波形数据
- `src/App.vue`综合可交互 demo`src/data` 中提供示例波形数据
- `src/router.ts`Demo 路由;`src/views/FixedYDomainDemo.vue` 为固定振幅范围示例
## 本地开发

View File

@@ -1,6 +1,6 @@
{
"name": "waveform-analysis",
"version": "0.1.23",
"version": "0.1.29",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/types/index.d.ts",
@@ -38,6 +38,7 @@
"dependencies": {
"ant-design-vue": "3.2.20",
"d3": "^7.9.0",
"vue-router": "^4.6.4",
"vue3-colorpicker": "^2.3.0"
},
"peerDependencies": {

18
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
d3:
specifier: ^7.9.0
version: 7.9.0
vue-router:
specifier: ^4.6.4
version: 4.6.4(vue@3.5.40(typescript@6.0.3))
vue3-colorpicker:
specifier: ^2.3.0
version: 2.3.0(@aesoper/normal-utils@0.1.5)(@popperjs/core@2.11.8)(@vueuse/core@10.11.1(vue@3.5.40(typescript@6.0.3)))(gradient-parser@1.2.0)(lodash-es@4.18.1)(tinycolor2@1.6.0)(vue-types@4.2.1(vue@3.5.40(typescript@6.0.3)))(vue@3.5.40(typescript@6.0.3))
@@ -632,6 +635,9 @@ packages:
'@vue/compiler-ssr@3.5.40':
resolution: {integrity: sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==}
'@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/language-core@3.3.7':
resolution: {integrity: sha512-LzmkKinXAMMoh8Jfi/jMUSDUjuPdv8mynH5WJGKfXyZtDw3hQ6GBaoI6Bcnl/Xqlu32q/0Z6i/trp4VXykzyLw==}
@@ -1704,6 +1710,11 @@ packages:
peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
vue-router@4.6.4:
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
peerDependencies:
vue: ^3.5.0
vue-tsc@3.3.7:
resolution: {integrity: sha512-+C+rgD49wAQ5bUTl2sp5a8Bzg4YoldMNXM+g7CFe604MYcQ8PrZPMQhIjJSzKXtPBCa+C5ayMipqjbA7splekQ==}
hasBin: true
@@ -2378,6 +2389,8 @@ snapshots:
'@vue/compiler-dom': 3.5.40
'@vue/shared': 3.5.40
'@vue/devtools-api@6.6.4': {}
'@vue/language-core@3.3.7':
dependencies:
'@volar/language-core': 2.4.28
@@ -3386,6 +3399,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
vue-router@4.6.4(vue@3.5.40(typescript@6.0.3)):
dependencies:
'@vue/devtools-api': 6.6.4
vue: 3.5.40(typescript@6.0.3)
vue-tsc@3.3.7(typescript@6.0.3):
dependencies:
'@volar/typescript': 2.4.28

View File

@@ -23,6 +23,7 @@ import { createSimulatedWaveformData } from './data/simulatedWaveforms'
import DemoChartHost from './demo/DemoChartHost.vue'
import DemoControlPanel from './demo/DemoControlPanel.vue'
import type { DemoChartModel, DemoControlPanelModel } from './demo/types'
import { useDemoXAxisLabelControls } from './demo/useDemoXAxisLabelControls'
const fullChartData = createSimulatedWaveformData()
const displayMode = ref<WaveformDisplayMode>('independent')
@@ -40,6 +41,7 @@ const verticalGridVisible = ref(true)
const verticalGridColor = ref('#dfe5ef')
const xAxisLineVisible = ref(false)
const yAxisLineVisible = ref(false)
const { controlModel: xAxisLabelControlModel, xAxisLabelFormatter } = useDemoXAxisLabelControls()
const annotations = ref<WaveformAnnotation[]>([])
const annotationsVisible = ref(true)
const cleanView = ref(false)
@@ -112,7 +114,10 @@ const frameStyle = computed<WaveformFrameStyle>(() => ({
backgroundColor: frameBackgroundColor.value,
}))
const axes = computed<WaveformAxesOptions>(() => ({
x: { lineVisible: xAxisLineVisible.value },
x: {
lineVisible: xAxisLineVisible.value,
...(xAxisLabelFormatter.value ? { labelFormatter: xAxisLabelFormatter.value } : {}),
},
y: { lineVisible: yAxisLineVisible.value },
}))
const zeroLine = computed<WaveformZeroLineOptions>(() => ({
@@ -308,6 +313,7 @@ const controlPanelModel = reactive({
verticalGridColor,
xAxisLineVisible,
yAxisLineVisible,
...xAxisLabelControlModel,
frameBorderColor,
frameBackgroundColor,
frameBorderWidth,

14
src/DemoRouterApp.vue Normal file
View File

@@ -0,0 +1,14 @@
<template>
<div class="demo-shell">
<header class="demo-shell__header">
<RouterLink class="demo-shell__brand" to="/">Waveform Analysis</RouterLink>
<nav class="demo-shell__nav" aria-label="示例导航">
<RouterLink to="/">综合示例</RouterLink>
<RouterLink to="/fixed-y-domain">固定振幅范围</RouterLink>
</nav>
</header>
<div class="demo-shell__content">
<RouterView />
</div>
</div>
</template>

View File

@@ -0,0 +1,116 @@
import { zoomIdentity } from 'd3'
import { describe, expect, it } from 'vitest'
import { DEFAULT_WAVEFORM_RENDERING_OPTIONS } from '../../core'
import type { DisplaySeries, DisplayTrack } from './types'
import { buildTrackLayouts, resolveYAxisSeriesGroups } from './layout'
function series(id: string, minimum: number, maximum: number): DisplaySeries {
return {
id,
name: id,
color: '#1677ff',
lineType: 'linear',
lineStyle: 'solid',
pointType: 'none',
errorBar: { visible: false, width: 1.5, capWidth: 8 },
points: [
{ x: 0, y: minimum },
{ x: 1, y: maximum },
],
xDomain: [0, 1],
yDomain: [minimum, maximum],
hasErrorPoints: false,
}
}
function track(seriesList: DisplaySeries[]): DisplayTrack {
return {
id: 'track',
series: seriesList,
visibleSeries: seriesList,
xDomain: [0, 1],
yDomain: [0, 100],
}
}
describe('fixed Y-domain layout', () => {
it('uses an exact global fixed domain without applying nice bounds', () => {
const sourceTrack = track([series('a', 0, 100)])
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: sourceTrack,
},
],
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
displayMode: 'independent',
overlayMode: 'single-axis',
independentTransforms: [zoomIdentity],
sharedZoomDomain: [0, 1],
fixedYDomain: [3, 97],
timeUnit: 'ms',
rendering: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
hideSecondaryLabels: false,
yAxisLabelX: -50,
showCompactEmptyTracks: false,
})[0]
expect(result?.yScale.domain()).toEqual([3, 97])
expect(result?.yAxes[0]?.tickValues).toContain(3)
expect(result?.yAxes[0]?.tickValues).toContain(97)
expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([3, 97])
})
it('resolves track, series, and global fixed-domain precedence', () => {
const sourceTrack = track([series('a', 0, 10), series('b', 20, 30)])
expect(
resolveYAxisSeriesGroups(sourceTrack, 'single-axis', [-5, 5], {
a: [-10, 10],
track: [300, 100],
})[0]?.domain,
).toEqual([100, 300])
expect(
resolveYAxisSeriesGroups(sourceTrack, 'single-axis', [-5, 5], {
a: [-10, 10],
})[0]?.domain,
).toEqual([-10, 10])
})
it('keeps per-series fixed domains on separate axes and merges axis overflow', () => {
const sourceTrack = track([
series('a', 0, 1),
series('b', 10, 11),
series('c', 20, 21),
series('d', 30, 31),
series('e', 40, 41),
])
const groups = resolveYAxisSeriesGroups(sourceTrack, 'multi-axis', undefined, {
a: [-1, 1],
b: [-2, 2],
c: [-3, 3],
d: [-4, 4],
e: [-5, 5],
})
expect(groups.map((group) => group.domain)).toEqual([
[-1, 1],
[-2, 2],
[-3, 3],
[-5, 5],
])
expect(groups.every((group) => group.fixed)).toBe(true)
})
})

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

View File

@@ -1,12 +1,18 @@
import { scaleLinear } from 'd3'
import type { WaveformOverlayMode } from '../../types'
import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../../utils'
import { formatScientificAxisLabel, paddedDomain } from '../../utils'
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
import { MAX_MULTI_Y_AXIS_COUNT, Y_AXIS_EXPONENT_GAP } from './constants'
import { MAX_MULTI_Y_AXIS_COUNT } from './constants'
import {
mergeYDomains,
resolveSeriesFixedYDomain,
resolveTrackFixedYDomain,
type WaveformYDomain,
} from './yDomain'
// 导出常量供外部使用
export { MAX_MULTI_Y_AXIS_COUNT, Y_AXIS_EXPONENT_GAP } from './constants'
export { MAX_MULTI_Y_AXIS_COUNT } from './constants'
const Y_AXIS_CHARACTER_WIDTH = 7
const Y_AXIS_TICK_PADDING = 7
@@ -14,11 +20,12 @@ const Y_AXIS_OUTER_PADDING = 4
const Y_AXIS_LABEL_GAP = 6
const Y_AXIS_LABEL_BAND_WIDTH = 24
interface YAxisSeriesGroup {
export interface YAxisSeriesGroup {
index: number
side: 'left' | 'right'
seriesList: DisplaySeries[]
domain: [number, number]
fixed: boolean
}
function resolveAxisSides(axisCount: number): Array<'left' | 'right'> {
@@ -53,6 +60,7 @@ export function buildYAxisSeriesGroups(
side: sides[index],
seriesList: [] as DisplaySeries[],
domain: [0, 1] as [number, number],
fixed: false,
}))
track.visibleSeries.forEach((series, index) => {
@@ -72,35 +80,49 @@ export function buildYAxisSeriesGroups(
return grouped
}
export function axisTextMetrics(domain: [number, number]): {
exponentLabel: string | null
exponentWidth: number
tickTextWidth: number
} {
const scale = scaleLinear(domain, [1, 0]).nice()
export function resolveYAxisSeriesGroups(
track: DisplayTrack,
overlayMode: WaveformOverlayMode,
yDomain?: WaveformYDomain,
yDomains?: Record<string, WaveformYDomain>,
): YAxisSeriesGroup[] {
const trackDomain = resolveTrackFixedYDomain(track, yDomains)
return buildYAxisSeriesGroups(track, overlayMode).map((group) => {
if (trackDomain) return { ...group, domain: trackDomain, fixed: true }
const seriesDomains = group.seriesList.map(
(series) => resolveSeriesFixedYDomain(series, yDomain, yDomains) ?? series.yDomain,
)
const fixed = group.seriesList.some((series) =>
resolveSeriesFixedYDomain(series, yDomain, yDomains),
)
return fixed ? { ...group, domain: mergeYDomains(seriesDomains), fixed } : group
})
}
export function axisTextMetrics(
domain: [number, number],
nice = true,
tickValues?: number[],
): { tickTextWidth: number } {
const scale = scaleLinear(domain, [1, 0])
if (nice) scale.nice()
const [axisMin, axisMax] = scale.domain()
const values = scale.ticks(10)
const values = tickValues ?? Array.from(new Set([axisMin, ...scale.ticks(10), axisMax]))
const topTickValue = Math.max(...values)
const maximumTickCharacters = Math.max(
1,
...values.map((value) => formatScientificAxisLabel(value, { axisMin, axisMax }).length),
...values.map(
(value) => formatScientificAxisLabel(value, { axisMin, axisMax, topTickValue }).length,
),
)
const exponentLabel = formatScientificAxisExponent(axisMin, axisMax)
return {
exponentLabel,
exponentWidth: exponentLabel ? exponentLabel.length * Y_AXIS_CHARACTER_WIDTH : 0,
tickTextWidth: maximumTickCharacters * Y_AXIS_CHARACTER_WIDTH,
}
}
function axisExponentClearance(domain: [number, number]): number {
const { exponentLabel, exponentWidth } = axisTextMetrics(domain)
return exponentLabel ? exponentWidth + Y_AXIS_EXPONENT_GAP : 0
}
export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
return (
axisTextMetrics(group.domain).tickTextWidth +
axisExponentClearance(group.domain) +
axisTextMetrics(group.domain, !group.fixed).tickTextWidth +
Y_AXIS_TICK_PADDING +
Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH +
@@ -110,8 +132,7 @@ export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number {
return (
axisTextMetrics(group.domain).tickTextWidth +
axisExponentClearance(group.domain) +
axisTextMetrics(group.domain, !group.fixed).tickTextWidth +
Y_AXIS_TICK_PADDING +
Y_AXIS_OUTER_PADDING
)
@@ -120,8 +141,10 @@ function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number {
export function measureTrackYAxisClearance(
track: DisplayTrack,
overlayMode: WaveformOverlayMode,
yDomain?: WaveformYDomain,
yDomains?: Record<string, WaveformYDomain>,
): { left: number; right: number } {
return buildYAxisSeriesGroups(track, overlayMode).reduce(
return resolveYAxisSeriesGroups(track, overlayMode, yDomain, yDomains).reduce(
(clearance, group) => {
clearance[group.side] +=
overlayMode === 'multi-axis' || track.visibleSeries.length === 1

View File

@@ -12,16 +12,20 @@ import {
selectSeriesRenderPoints,
type ResolvedWaveformRenderingOptions,
} from '../../core/rendering'
import type { WaveformDisplayMode, WaveformOverlayMode, WaveformPoint } from '../../types'
import { buildMinorTicks, formatAxisTimeExponent, formatEndpointTime } from '../../utils'
import type {
WaveformDisplayMode,
WaveformOverlayMode,
WaveformPoint,
WaveformXAxisLabelFormatter,
} from '../../types'
import { buildMinorTicks, formatXAxisLabel } from '../../utils'
import {
getBottomRowCellIndexes,
type GridCellGeometry,
type NormalizedWaveformGridOptions,
} from './grid'
import { axisTextMetrics, buildYAxisSeriesGroups } from './layout'
import { axisTextMetrics, resolveYAxisSeriesGroups } from './layout'
import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
import { Y_AXIS_EXPONENT_GAP } from './constants'
import {
Y_AXIS_LABEL_BAND_WIDTH,
Y_AXIS_LABEL_GAP,
@@ -42,8 +46,11 @@ export interface BuildTrackLayoutsOptions {
sharedZoomDomain: [number, number]
initialXDomain?: [number, number]
initialXDomains?: Record<string, [number, number]>
fixedYDomain?: [number, number]
fixedYDomains?: Record<string, [number, number]>
yDomains?: Record<string, [number, number]>
timeUnit: 's' | 'ms'
xAxisLabelFormatter?: WaveformXAxisLabelFormatter
rendering: ResolvedWaveformRenderingOptions
hideSecondaryLabels: boolean
yAxisLabelX: number
@@ -84,6 +91,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
? scaleLinear(
options.initialXDomains?.[displayTrack.id] ??
options.initialXDomains?.[series.id] ??
options.initialXDomain ??
displayTrack.xDomain,
[0, cell.width],
)
@@ -94,13 +102,18 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
: zoomIdentity
const xScale = transform.rescaleX(baseXScale)
const configuredYDomain = options.yDomains?.[displayTrack.id]
const yAxisGroups = buildYAxisSeriesGroups(displayTrack, options.overlayMode).map((group) => ({
...group,
domain: configuredYDomain ?? group.domain,
}))
const yAxisGroups = resolveYAxisSeriesGroups(
displayTrack,
options.overlayMode,
options.fixedYDomain,
options.fixedYDomains,
).map((group) =>
!group.fixed && configuredYDomain ? { ...group, domain: configuredYDomain } : group,
)
const sideOffsets = { left: 0, right: 0 }
const yAxes: WaveformYAxisLayout[] = yAxisGroups.map((group) => {
const scale = scaleLinear(group.domain, [cell.plotHeight, 0]).nice()
const scale = scaleLinear(group.domain, [cell.plotHeight, 0])
if (!group.fixed) scale.nice()
const majorTicks = scale.ticks(Math.max(2, Math.floor(cell.plotHeight / 55)))
const [axisStart, axisEnd] = scale.domain()
const showAxisEnd = options.displayMode !== 'compact' || cell.row === 0
@@ -110,28 +123,16 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
const tickValues = Array.from(
new Set([axisStart, ...visibleMajorTicks, ...(showAxisEnd ? [axisEnd] : [])]),
)
const { exponentLabel, exponentWidth, tickTextWidth } = axisTextMetrics(group.domain)
const exponentClearance = exponentLabel ? exponentWidth + Y_AXIS_EXPONENT_GAP : 0
const { tickTextWidth } = axisTextMetrics(group.domain, !group.fixed, tickValues)
const clearance =
tickTextWidth +
Y_AXIS_TICK_PADDING +
exponentClearance +
Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH +
Y_AXIS_OUTER_PADDING
const x = group.side === 'left' ? -sideOffsets.left : cell.width + sideOffsets.right
const exponentX =
x +
(group.side === 'left'
? -(Y_AXIS_TICK_PADDING + tickTextWidth + Y_AXIS_EXPONENT_GAP)
: Y_AXIS_TICK_PADDING + tickTextWidth + Y_AXIS_EXPONENT_GAP)
const labelDistance =
tickTextWidth +
Y_AXIS_TICK_PADDING +
exponentClearance +
exponentWidth +
Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH / 2
tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_BAND_WIDTH / 2
const labelX = x + (group.side === 'left' ? -labelDistance : labelDistance)
sideOffsets[group.side] += clearance
return {
@@ -139,8 +140,6 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
side: group.side,
x,
labelX,
exponentX,
exponentLabel,
scale,
majorTicks,
minorTicks: buildMinorTicks(majorTicks),
@@ -154,10 +153,21 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
const yAxisTickValues = yAxes[0]?.tickValues ?? []
const domain = xScale.domain() as [number, number]
const endpointLabels = {
start: formatEndpointTime(domain[0], domain, options.timeUnit),
end: formatEndpointTime(domain[1], domain, options.timeUnit),
start: formatXAxisLabel(
domain[0],
domain,
options.timeUnit,
'start',
options.xAxisLabelFormatter,
),
end: formatXAxisLabel(
domain[1],
domain,
options.timeUnit,
'end',
options.xAxisLabelFormatter,
),
}
const xAxisExponent = formatAxisTimeExponent(domain, options.timeUnit)
const leftClearance = endpointLabels.start.length * 7 + 10
const rightClearance = endpointLabels.end.length * 7 + 10
const xAxisTickValues = xMajorTicks.filter((tick) => {
@@ -224,7 +234,6 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
yAxisTickValues,
xAxisTickValues,
endpointLabels,
xAxisExponent,
path: seriesPaths[0]?.path ?? null,
seriesPaths,
gridLines: options.grid.trackLines[displayTrack.id] ?? {

View File

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

View File

@@ -6,6 +6,7 @@ import {
shallowReactive,
shallowRef,
toRefs,
watch,
watchEffect,
type ComponentPublicInstance,
type Ref,
@@ -68,6 +69,15 @@ export function useWaveformChartController(
const annotationInteraction = useWaveformAnnotationInteraction()
const editorSeriesOptions = ref<AnnotationSeriesCandidate[]>([])
const hoverThrottle = useAnimationFrameThrottle()
watch(
[() => props.yDomain, () => props.yDomains],
() => {
sharedYDomains.value = {}
independentYDomains.value = {}
},
{ deep: true },
)
const selection = ref<ViewportSelectionState | null>(null)
const spacePressed = ref(false)
const pointerInsideChart = ref(false)

View File

@@ -2,7 +2,7 @@ import { scaleLinear, type ZoomTransform } from 'd3'
import { computed, type ComputedRef, type Ref, type ShallowRef } from 'vue'
import { resolveWaveformRenderingOptions } from '../../core'
import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../../utils'
import { paddedDomain } from '../../utils'
import {
layoutAnnotations,
type AnnotationSeriesInfo,
@@ -26,7 +26,12 @@ import {
resolveGridCellGeometry,
X_AXIS_BAND,
} from './grid'
import { buildTrackLayouts, measureTrackYAxisClearance, Y_AXIS_EXPONENT_GAP } from './layout'
import {
buildTrackLayouts,
axisTextMetrics,
measureTrackYAxisClearance,
resolveYAxisSeriesGroups,
} from './layout'
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
import type { PreparedWaveformSeries } from './useWaveformData'
import type { ResolvedWaveformChartProps } from './waveformChartTypes'
@@ -104,39 +109,21 @@ export function useWaveformLayout(context: LayoutContext) {
const yAxisMetrics = computed(() => {
const axisText = chartTracks.value
.filter((track) => track.visibleSeries.length > 0)
.map((track) => {
const scale = scaleLinear(track.yDomain, [1, 0]).nice()
const [axisMin, axisMax] = scale.domain()
return {
exponentLabel: formatScientificAxisExponent(axisMin, axisMax),
tickLabels: scale
.ticks(10)
.map((value) => formatScientificAxisLabel(value, { axisMin, axisMax })),
}
})
const maximumCharacters = Math.max(
1,
...axisText.flatMap(({ tickLabels }) => tickLabels).map((label) => label.length),
)
const tickTextWidth = maximumCharacters * Y_AXIS_CHARACTER_WIDTH
const maximumExponentWidth = Math.max(
0,
...axisText.map(({ exponentLabel }) => (exponentLabel?.length ?? 0) * Y_AXIS_CHARACTER_WIDTH),
)
const exponentClearance = maximumExponentWidth ? maximumExponentWidth + Y_AXIS_EXPONENT_GAP : 0
const tickClearance =
tickTextWidth + Y_AXIS_TICK_PADDING + exponentClearance + Y_AXIS_OUTER_PADDING
.flatMap((track) =>
resolveYAxisSeriesGroups(track, props.overlayMode, props.yDomain, props.yDomains),
)
.map((group) => axisTextMetrics(group.domain, !group.fixed).tickTextWidth)
const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText)
const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING
const labelCenterX = -(
Y_AXIS_TICK_PADDING +
tickTextWidth +
exponentClearance +
Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH / 2
)
const fullClearance =
tickTextWidth +
Y_AXIS_TICK_PADDING +
exponentClearance +
Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH +
Y_AXIS_OUTER_PADDING
@@ -165,7 +152,12 @@ export function useWaveformLayout(context: LayoutContext) {
const multiAxisClearance = computed(() =>
chartTracks.value.reduce(
(maximum, track) => {
const clearance = measureTrackYAxisClearance(track, props.overlayMode)
const clearance = measureTrackYAxisClearance(
track,
props.overlayMode,
props.yDomain,
props.yDomains,
)
return {
left: Math.max(maximum.left, clearance.left),
right: Math.max(maximum.right, clearance.right),
@@ -281,6 +273,8 @@ export function useWaveformLayout(context: LayoutContext) {
sharedZoomDomain: sharedZoomDomain.value,
initialXDomain: props.initialXDomain ? initialXDomain.value : undefined,
initialXDomains: props.initialXDomains,
fixedYDomain: props.yDomain,
fixedYDomains: props.yDomains,
yDomains:
props.displayMode === 'independent'
? Object.fromEntries(
@@ -291,6 +285,7 @@ export function useWaveformLayout(context: LayoutContext) {
)
: sharedYDomains.value,
timeUnit: props.timeUnit,
xAxisLabelFormatter: props.axes?.x?.labelFormatter,
rendering: renderingOptions.value,
hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
yAxisLabelX: yAxisMetrics.value.labelCenterX,

View File

@@ -31,6 +31,8 @@ export interface WaveformChartProps {
minVisiblePoints?: number
initialXDomain?: [number, number]
initialXDomains?: Record<string, [number, number]>
yDomain?: [number, number]
yDomains?: Record<string, [number, number]>
timeUnit?: 's' | 'ms'
frameNumber?: string | number
frameStyle?: WaveformFrameStyle

View File

@@ -0,0 +1,56 @@
import { describe, expect, it } from 'vitest'
import type { DisplayTrack } from './types'
import { hasFixedYDomainForTrack, mergeYDomains, normalizeYDomain } from './yDomain'
const track: DisplayTrack = {
id: 'shared-track',
series: [],
visibleSeries: [
{
id: 'channel-a',
name: 'A',
color: '#1677ff',
lineType: 'linear',
lineStyle: 'solid',
pointType: 'none',
errorBar: { visible: false, width: 1.5, capWidth: 8 },
points: [],
xDomain: [0, 1],
yDomain: [0, 1],
hasErrorPoints: false,
},
],
xDomain: [0, 1],
yDomain: [0, 1],
}
describe('fixed Y domains', () => {
it('normalizes valid reversed domains', () => {
expect(normalizeYDomain([97, 3])).toEqual([3, 97])
expect(normalizeYDomain([-10, 10])).toEqual([-10, 10])
})
it.each([undefined, [1, 1], [Number.NaN, 1], [0, Number.POSITIVE_INFINITY]])(
'rejects an invalid domain: %j',
(domain) => {
expect(normalizeYDomain(domain as [number, number] | undefined)).toBeUndefined()
},
)
it('detects valid track, series, and global configuration only', () => {
expect(hasFixedYDomainForTrack(track, [-1, 1])).toBe(true)
expect(hasFixedYDomainForTrack(track, undefined, { 'shared-track': [-2, 2] })).toBe(true)
expect(hasFixedYDomainForTrack(track, undefined, { 'channel-a': [-3, 3] })).toBe(true)
expect(hasFixedYDomainForTrack(track, [0, 0], { 'channel-a': [1, 1] })).toBe(false)
})
it('merges configured and automatic series domains without padding', () => {
expect(
mergeYDomains([
[3, 97],
[-20, 40],
]),
).toEqual([-20, 97])
})
})

View File

@@ -0,0 +1,50 @@
import type { DisplaySeries, DisplayTrack } from './types'
export type WaveformYDomain = [number, number]
export function normalizeYDomain(
domain: readonly [number, number] | undefined,
): WaveformYDomain | undefined {
if (
!domain ||
!Number.isFinite(domain[0]) ||
!Number.isFinite(domain[1]) ||
domain[0] === domain[1]
) {
return undefined
}
return domain[0] < domain[1] ? [domain[0], domain[1]] : [domain[1], domain[0]]
}
export function resolveTrackFixedYDomain(
track: Pick<DisplayTrack, 'id'>,
yDomains?: Record<string, WaveformYDomain>,
): WaveformYDomain | undefined {
return normalizeYDomain(yDomains?.[track.id])
}
export function resolveSeriesFixedYDomain(
series: Pick<DisplaySeries, 'id'>,
yDomain?: WaveformYDomain,
yDomains?: Record<string, WaveformYDomain>,
): WaveformYDomain | undefined {
return normalizeYDomain(yDomains?.[series.id]) ?? normalizeYDomain(yDomain)
}
export function hasFixedYDomainForTrack(
track: Pick<DisplayTrack, 'id' | 'visibleSeries'>,
yDomain?: WaveformYDomain,
yDomains?: Record<string, WaveformYDomain>,
): boolean {
return Boolean(
resolveTrackFixedYDomain(track, yDomains) ||
track.visibleSeries.some((series) => resolveSeriesFixedYDomain(series, yDomain, yDomains)),
)
}
export function mergeYDomains(domains: readonly WaveformYDomain[]): WaveformYDomain {
return [
Math.min(...domains.map((domain) => domain[0])),
Math.max(...domains.map((domain) => domain[1])),
]
}

View File

@@ -91,6 +91,22 @@ describe('WaveformTooltip', () => {
expect(wrapper.get('.waveform-tooltip__value').text()).toBe('-1,405.4932 A')
})
it('formats tooltip time with at most four decimal places', () => {
const wrapper = mount(WaveformTooltip, {
props: {
visible: true,
position: { x: 10, y: 10 },
timeUnit: 's',
hoveredPoint: { x: 1.234567, y: 12 },
seriesPoints: [],
containerWidth: 400,
containerHeight: 300,
},
})
expect(wrapper.get('.waveform-tooltip__time').text()).toBe('s: 1.2346')
})
it('omits the error label when both resolved errors are zero', () => {
const point = { x: 1, y: 12 }
const wrapper = mount(WaveformTooltip, {

View File

@@ -3,6 +3,7 @@ import { computed, nextTick, type ComputedRef, type Ref, type ShallowRef } from
import { MINIMUM_SELECTION_SIZE } from '../core/constants'
import type { DisplayTrack, TrackLayout } from '../core/types'
import { hasFixedYDomainForTrack } from '../core/yDomain'
import type {
ResolvedWaveformChartProps,
ViewportSelectionState,
@@ -190,9 +191,13 @@ export function useWaveformViewport(context: ViewportContext) {
const nextIndependentDomains = { ...independentYDomains.value }
const nextSharedDomains = { ...sharedYDomains.value }
targets.forEach((target) => {
const chartTrack = chartTracks.value.find((item) => item.id === target.id)
if (chartTrack && hasFixedYDomainForTrack(chartTrack, props.yDomain, props.yDomains)) {
return
}
const key = target.series.trackId ?? target.series.id
const source = active.yDomains[key] ?? (target.yScale.domain() as [number, number])
const boundary = chartTracks.value.find((item) => item.id === key)?.yDomain ?? source
const boundary = chartTrack?.yDomain ?? source
const ySpan = source[1] - source[0]
const nextY = clampDomain(
[source[0] + (dy / height) * ySpan, source[1] + (dy / height) * ySpan],

View File

@@ -3,6 +3,7 @@ import {
select,
zoom,
zoomIdentity,
zoomTransform,
type D3ZoomEvent,
type ZoomBehavior,
type ZoomTransform,
@@ -237,6 +238,15 @@ export function useWaveformZoom(context: ZoomContext) {
const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
return tracks.length > 0 && tracks.every(canZoomTrack)
}
const canHandleWheelZoom = (event: Event, canZoomIn: boolean): boolean => {
if (event.type !== 'wheel') return false
const deltaY = (event as WheelEvent).deltaY
if (deltaY < 0) return canZoomIn
if (deltaY > 0) {
return zoomTransform(event.currentTarget as Element).k > ZOOM_CONSTRAINTS.MIN_SCALE
}
return false
}
const configureZoom = () => {
clearZoomBindings()
if (
@@ -256,10 +266,7 @@ export function useWaveformZoom(context: ZoomContext) {
if (!overlay) return
const dataDomain = resolveInitialTrackDomain(track)
const behavior = zoom<SVGRectElement, unknown>()
.filter(
(event) =>
event.type === 'wheel' && (event as WheelEvent).deltaY < 0 && canZoomTrack(track),
)
.filter((event) => canHandleWheelZoom(event, canZoomTrack(track)))
.scaleExtent([1, resolveMaximumZoomScale(dataDomain)])
.extent([
[0, 0],
@@ -287,10 +294,7 @@ export function useWaveformZoom(context: ZoomContext) {
const overlay = sharedOverlayElement.value
if (!overlay) return
const behavior = zoom<SVGRectElement, unknown>()
.filter(
(event) =>
event.type === 'wheel' && (event as WheelEvent).deltaY < 0 && canZoomSharedTracks(),
)
.filter((event) => canHandleWheelZoom(event, canZoomSharedTracks()))
.scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value)])
.extent([
[0, 0],

View File

@@ -94,7 +94,7 @@ const resolvedFrameStyle = computed(() => {
/>
<rect
v-if="!track.isEmpty && !cleanView"
v-if="!track.isEmpty"
class="waveform-track__plot-frame waveform-chart__plot-frame"
:width="track.width ?? innerWidth"
:height="track.height"

View File

@@ -3,7 +3,7 @@ import { axisBottom, axisLeft, axisRight, select } from 'd3'
import { nextTick, onMounted, ref, watch } from 'vue'
import type { WaveformAxesOptions } from '../../types'
import { formatAxisTime, formatScientificAxisLabel } from '../../utils'
import { formatScientificAxisLabel, formatXAxisLabel } from '../../utils'
import type { DisplaySeries, TrackLayout, WaveformYAxisLayout } from '../core/types'
interface Props {
@@ -43,8 +43,11 @@ function renderAxes() {
const element = yAxisElements.value[index]
if (!element) return
const [axisMin, axisMax] = axis.scale.domain()
const topTickValue = Math.max(...axis.tickValues)
const yAxis = (axis.side === 'left' ? axisLeft(axis.scale) : axisRight(axis.scale))
.tickFormat((value) => formatScientificAxisLabel(Number(value), { axisMin, axisMax }))
.tickFormat((value) =>
formatScientificAxisLabel(Number(value), { axisMin, axisMax, topTickValue }),
)
.tickSize(-4)
.tickPadding(7)
.tickSizeOuter(0)
@@ -63,10 +66,12 @@ function renderAxes() {
axisBottom(props.track.xScale)
.tickValues(props.track.xAxisTickValues)
.tickFormat((value) =>
formatAxisTime(
formatXAxisLabel(
Number(value),
props.timeUnit,
props.track.xScale.domain() as [number, number],
props.timeUnit,
'tick',
props.axes?.x?.labelFormatter,
),
)
.tickSize(-4)
@@ -90,6 +95,7 @@ watch(
() => props.track.xAxisTickValues,
() => props.track.yAxisTickValues,
() => props.timeUnit,
() => props.axes?.x?.labelFormatter,
() => props.axes?.x?.lineVisible,
() => props.axes?.y?.lineVisible,
],
@@ -103,14 +109,14 @@ watch(
<template>
<g
v-if="track.showXAxis && !cleanView"
v-if="track.showXAxis"
ref="xAxisElement"
class="waveform-track__axis waveform-track__axis--x waveform-chart__axis waveform-chart__axis--x"
:class="{ 'waveform-track__axis--line-hidden': axes?.x?.lineVisible === false }"
:transform="`translate(0, ${track.height})`"
/>
<g
v-if="track.showXAxis && !cleanView"
v-if="track.showXAxis"
class="waveform-track__axis-endpoints waveform-chart__axis-endpoints"
:transform="`translate(0, ${track.height})`"
font-family="sans-serif"
@@ -136,19 +142,8 @@ watch(
{{ track.endpointLabels.end }}
</text>
</g>
<text
v-if="track.showXAxis && track.xAxisExponent && !cleanView"
class="waveform-track__axis-exponent waveform-track__axis-exponent--x waveform-chart__axis-exponent waveform-chart__axis-exponent--x"
:x="track.width ?? innerWidth"
:y="track.height + 27"
text-anchor="end"
aria-hidden="true"
>
{{ track.xAxisExponent }}
</text>
<g
v-for="axis in track.isEmpty || cleanView ? [] : track.yAxes"
v-for="axis in track.isEmpty ? [] : track.yAxes"
:key="`y-axis-${track.index}-${axis.index}`"
:ref="(element) => setYAxisElement(element, axis.index)"
class="waveform-track__axis waveform-track__axis--y waveform-chart__axis waveform-chart__axis--y"
@@ -160,22 +155,6 @@ watch(
:data-y-axis-side="axis.side"
:transform="`translate(${axis.x}, 0)`"
/>
<text
v-for="axis in track.isEmpty || cleanView
? []
: track.yAxes.filter((item) => item.exponentLabel)"
:key="`y-axis-exponent-${track.index}-${axis.index}`"
class="waveform-track__axis-exponent waveform-track__axis-exponent--y waveform-chart__axis-exponent waveform-chart__axis-exponent--y"
:data-y-axis-index="axis.index"
:x="axis.exponentX"
y="0"
dy="0.32em"
:text-anchor="axis.side === 'left' ? 'end' : 'start'"
aria-hidden="true"
>
{{ axis.exponentLabel }}
</text>
<g
v-if="
!cleanView &&

View File

@@ -72,7 +72,7 @@ describe('WaveformChart', () => {
expect(endTicks[3]).toHaveLength(0)
})
it('keeps one separate shared exponent for every compact Y axis', async () => {
it('prefixes one shared exponent to the largest visible tick on every compact Y axis', async () => {
const wrapper = await mountSizedChart(
{
kind: 'series',
@@ -83,7 +83,7 @@ describe('WaveformChart', () => {
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 254 },
{ x: 1, y: 2540 },
],
},
},
@@ -103,14 +103,14 @@ describe('WaveformChart', () => {
)
const axes = wrapper.findAll('.waveform-chart__axis--y')
const exponents = wrapper.findAll('.waveform-chart__axis-exponent--y')
expect(axes).toHaveLength(2)
expect(exponents.map((label) => label.text())).toEqual(['E+02', 'E-04'])
axes.forEach((axis) => {
axes.forEach((axis, index) => {
const labels = axis.findAll('.tick text').map((tick) => tick.text())
expect(labels.every((label) => !label.startsWith('E'))).toBe(true)
expect(labels.every((label) => /^-?\d+\.\d{2}$/.test(label))).toBe(true)
const exponentLabels = labels.filter((label) => label.startsWith('E'))
expect(exponentLabels).toHaveLength(1)
expect(exponentLabels[0]).toMatch(index === 0 ? /^E\+03 / : /^E-04 /)
})
expect(wrapper.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
})
it('prefers a trimmed series name and falls back to yLabel for unnamed data', async () => {

View File

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

View File

@@ -62,15 +62,15 @@ describe('WaveformChart', () => {
],
}
const wrapper = await mountSizedChart(firstData)
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
firstData.points.push({ x: 2, y: 2 })
await flushPromises()
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
await wrapper.setProps({ data: { ...firstData, points: [...firstData.points] } })
await flushPromises()
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
})
it('keeps controlled annotations when replacing the loaded data window', async () => {
@@ -170,7 +170,7 @@ describe('WaveformChart', () => {
expect(wrapper.findAll('.waveform-chart__track-label')).toHaveLength(0)
expect(
wrapper.findAll('.waveform-chart__axis-endpoint--end').map((item) => item.text()),
).toEqual(['1.00', '2.00'])
).toEqual(['1000', '2000'])
})
it('keeps the zero Y-axis label on upper compact tracks', async () => {

View File

@@ -1,7 +1,8 @@
import { flushPromises } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { flushAnimationFrames } from '../../test/setup'
import type { WaveformXAxisLabelFormatter } from '../../types'
import { type WaveformData } from '../waveform'
import { gridSeries, mountSizedChart } from '../../test/waveformChart'
@@ -162,35 +163,42 @@ describe('WaveformChart', () => {
expect(wrapper.get('.waveform-chart__axis-endpoint--end').attributes('x')).toBe(
String(trackWidth),
)
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4.99')
expect(wrapper.get('.waveform-chart__axis-exponent--x').text()).toBe('E+03')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4990.3')
expect(wrapper.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
})
it('uses one shared scientific exponent only for large and tiny Y-axis domains', async () => {
const cases = [
{ values: [0, 50], exponent: null },
{ values: [0, 254], exponent: 'E+02' },
{ values: [0, 0.0002], exponent: 'E-04' },
{ values: [0, 50], yDomain: [0, 50] as [number, number], exponent: null },
{ values: [1000, 3000], yDomain: [1000, 3000] as [number, number], exponent: 'E+03' },
{
values: [0.0001, 0.0003],
yDomain: [0.0001, 0.0003] as [number, number],
exponent: 'E-04',
},
]
for (const { values, exponent } of cases) {
const wrapper = await mountSizedChart({
kind: 'points',
for (const { values, yDomain, exponent } of cases) {
const data = {
kind: 'points' as const,
points: values.map((y, x) => ({ x, y })),
})
}
const originalValues = data.points.map((point) => point.y)
const wrapper = await mountSizedChart(data, { yDomain })
const labels = wrapper
.get('.waveform-chart__axis--y')
.findAll('.tick text')
.map((tick) => tick.text())
const exponentLabel = wrapper.find('.waveform-chart__axis-exponent--y')
const exponentLabels = labels.filter((label) => label.startsWith('E'))
if (exponent === null) {
expect(exponentLabel.exists()).toBe(false)
expect(exponentLabels).toHaveLength(0)
} else {
expect(exponentLabel.text()).toBe(exponent)
expect(labels.every((label) => !label.startsWith('E'))).toBe(true)
expect(labels.every((label) => /^-?\d+\.\d{2}$/.test(label))).toBe(true)
expect(exponentLabels).toHaveLength(1)
expect(exponentLabels[0]).toMatch(new RegExp(`^${exponent.replace('+', '\\+')} `))
}
expect(wrapper.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
expect(data.points.map((point) => point.y)).toEqual(originalValues)
wrapper.unmount()
}
@@ -212,13 +220,13 @@ describe('WaveformChart', () => {
expect(millisecondsChart.text()).toContain('时间ms')
expect(millisecondTicks.length).toBeGreaterThan(0)
expect(millisecondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
expect(millisecondsChart.get('.waveform-chart__axis-exponent--x').text()).toBe('E+03')
expect(millisecondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
expect(millisecondsChart.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
expect(millisecondsChart.find('.waveform-chart__watermark').exists()).toBe(false)
const secondsChart = await mountSizedChart(data, { timeUnit: 's', xLabel: 'Elapsed time' })
expect(secondsChart.text()).toContain('Elapsed time')
expect(secondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
expect(secondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1')
expect(secondsChart.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
})
@@ -237,15 +245,15 @@ describe('WaveformChart', () => {
expect(start.attributes('x')).toBe('0')
expect(start.attributes('text-anchor')).toBe('start')
expect(start.text()).toBe('0.00')
expect(start.text()).toBe('0')
expect(end.attributes('x')).toBe(
wrapper.get('.waveform-chart__track').attributes('data-track-width'),
)
expect(end.attributes('text-anchor')).toBe('end')
expect(end.text()).toBe('2.00')
expect(end.text()).toBe('1999')
expect(middleTickLabels.length).toBeGreaterThan(0)
expect(middleTickLabels).not.toContain('0.00')
expect(wrapper.get('.waveform-chart__axis-exponent--x').text()).toBe('E+03')
expect(middleTickLabels).not.toContain('0')
expect(wrapper.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
expect(wrapper.findAll('.waveform-chart__grid--major line').length).toBeGreaterThan(
middleTickLabels.length,
)
@@ -260,6 +268,70 @@ describe('WaveformChart', () => {
expect(endpointGroup.attributes('font-size')).toBe(xAxis.attributes('font-size'))
})
it('formats X-axis ticks and endpoints with display-unit values and source context', async () => {
const data: WaveformData = {
kind: 'points',
points: [
{ x: 0.125, y: 0 },
{ x: 1.875, y: 1 },
],
}
const originalPoints = data.points.map((point) => ({ ...point }))
const labelFormatter: WaveformXAxisLabelFormatter = (value, context) =>
`${context.kind}:${value / 10}`
const formatter = vi.fn(labelFormatter)
const wrapper = await mountSizedChart(data, {
timeUnit: 'ms',
axes: { x: { labelFormatter: formatter } },
})
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('start:12.5')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('end:187.5')
expect(
wrapper
.get('.waveform-chart__axis--x')
.findAll('.tick text')
.every((tick) => tick.text().startsWith('tick:')),
).toBe(true)
const startCall = formatter.mock.calls.find(([, context]) => context.kind === 'start')
const tickCall = formatter.mock.calls.find(([, context]) => context.kind === 'tick')
const endCall = formatter.mock.calls.find(([, context]) => context.kind === 'end')
expect(startCall).toEqual([
125,
{
kind: 'start',
rawValue: 0.125,
timeUnit: 'ms',
domain: [0.125, 1.875],
displayDomain: [125, 1875],
},
])
expect(tickCall?.[0]).toBeTypeOf('number')
expect(tickCall?.[1]).toMatchObject({
kind: 'tick',
timeUnit: 'ms',
domain: [0.125, 1.875],
displayDomain: [125, 1875],
})
expect(endCall?.[1]).toMatchObject({ kind: 'end', rawValue: 1.875 })
expect(data.points).toEqual(originalPoints)
await wrapper.setProps({
axes: { x: { labelFormatter: (value: number) => `updated:${value}` } },
})
await flushPromises()
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('updated:125')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('updated:1875')
expect(
wrapper
.get('.waveform-chart__axis--x')
.findAll('.tick text')
.every((tick) => tick.text().startsWith('updated:')),
).toBe(true)
expect(data.points).toEqual(originalPoints)
})
it('keeps zoom-change domains in source seconds', async () => {
const wrapper = await mountSizedChart({
kind: 'points',
@@ -300,6 +372,8 @@ describe('WaveformChart', () => {
)
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe(initialStart)
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).not.toBe(initialEnd)
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toContain('.')
expect(
Number.isFinite(Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())),
).toBe(true)
})
})

View File

@@ -59,7 +59,7 @@ describe('WaveformChart', () => {
await flushPromises()
expect(wrapper.findAll('.waveform-chart__axis--x')).toHaveLength(1)
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
})
it('updates rendering props and disables zoom interaction', async () => {

View File

@@ -33,10 +33,10 @@ describe('WaveformChart', () => {
tracks[0].get('.waveform-chart__y-axis-label-bg').attributes('x'),
)
expect(labelX).toBe(-103)
expect(labelX).toBe(-74)
expect(labelBackgroundX).toBe(labelX - 12)
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(119)
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(119)
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(90)
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(90)
})
it('keeps a tick-only gutter when channel labels are empty', async () => {
@@ -62,8 +62,8 @@ describe('WaveformChart', () => {
const secondLeft = Number(tracks[1].attributes('data-track-left'))
expect(wrapper.findAll('.waveform-chart__y-axis-label')).toHaveLength(0)
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(89)
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(89)
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(64)
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(60)
})
it('keeps the Y-axis label gutter stable while paging between value ranges', async () => {
@@ -122,7 +122,7 @@ describe('WaveformChart', () => {
const tracks = wrapper.findAll('.waveform-chart__track')
const firstWidth = Number(tracks[0].attributes('data-track-width'))
const secondLeft = Number(tracks[1].attributes('data-track-left'))
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(39)
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(32)
})
it('uses one shared overlay and bottom-row x axes for separated and compact grids', async () => {
@@ -225,7 +225,7 @@ describe('WaveformChart', () => {
}
})
it('uses each track x domain in independent mode instead of the shared initial domain', async () => {
it('uses the shared initial x domain for every independent track by default', async () => {
const wrapper = await mountSizedChart(
{
kind: 'series',
@@ -262,8 +262,10 @@ describe('WaveformChart', () => {
)
const tracks = wrapper.findAll('.waveform-chart__track')
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('6.00')
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5.00')
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8000')
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000')
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8000')
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000')
})
it('uses an explicit initial x domain override for an independent track', async () => {
@@ -291,7 +293,32 @@ 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 () => {
const wrapper = await mountSizedChart({
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 100 },
],
})
const yTickLabels = () =>
wrapper
.get('.waveform-chart__axis--y')
.findAll('.tick text')
.map((tick) => tick.text())
await wrapper.setProps({ yDomain: [3, 97] })
await flushPromises()
expect(yTickLabels()).toContain('3')
expect(yTickLabels()).toContain('97')
await wrapper.setProps({ yDomain: undefined })
await flushPromises()
expect(yTickLabels()).not.toContain('3')
expect(yTickLabels()).not.toContain('97')
})
it('keeps annotations bound to their channel while paging', async () => {

View File

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

View File

@@ -54,8 +54,9 @@ describe('WaveformChart', () => {
],
})
expect(first.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
expect(second.get('.waveform-chart__axis-exponent--y').text()).toBe('E+04')
expect(first.get('.waveform-chart__axis--y').text()).not.toContain('E')
expect(second.get('.waveform-chart__axis--y').text()).toContain('E+04 ')
expect(second.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
})
it('renders a configurable zero line only when the Y domain contains zero', async () => {
@@ -146,7 +147,7 @@ describe('WaveformChart', () => {
kind: 'points',
points: [
{ x: 0, y: -1 },
{ x: 1, y: 1 },
{ x: 10_000, y: 1 },
],
},
},
@@ -227,8 +228,8 @@ describe('WaveformChart', () => {
data: {
kind: 'points',
points: [
{ x: 0, y: 1 },
{ x: 1, y: 2 },
{ x: 0, y: 10_000 },
{ x: 10_000, y: 20_000 },
],
},
},
@@ -239,7 +240,7 @@ describe('WaveformChart', () => {
kind: 'points',
points: [
{ x: 0, y: 2 },
{ x: 1, y: 3 },
{ x: 10_000, y: 3 },
],
},
},
@@ -252,6 +253,8 @@ describe('WaveformChart', () => {
annotations: [{ id: 'note', seriesId: 'first', x: 0.5, y: 0, text: 'hidden note' }],
zeroLine: { visible: true },
title: { text: 'hidden title' },
axes: { x: { lineVisible: false }, y: { lineVisible: false } },
frameStyle: { borderColor: '#dc2626', borderWidth: 2, borderStyle: 'dashed' as const },
}
const regularWrapper = await mountSizedChart(data, sharedProps)
const wrapper = await mountSizedChart(data, {
@@ -285,13 +288,32 @@ describe('WaveformChart', () => {
expect(wrapper.find('.waveform-chart__overlay--independent').exists()).toBe(true)
expect(wrapper.get('.waveform-chart__title-area').attributes('aria-hidden')).toBe('true')
expect(wrapper.find('.waveform-chart__title-visual').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__axis').exists()).toBe(false)
const xAxis = wrapper.get('.waveform-chart__axis--x')
const yAxes = wrapper.findAll('.waveform-chart__axis--y')
expect(yAxes).toHaveLength(2)
expect(xAxis.findAll('.tick line').length).toBeGreaterThan(0)
expect(xAxis.findAll('.tick text').length).toBeGreaterThan(0)
yAxes.forEach((axis) => {
expect(axis.findAll('.tick line').length).toBeGreaterThan(0)
expect(axis.findAll('.tick text').length).toBeGreaterThan(0)
expect(axis.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.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
expect(yAxes.some((axis) => axis.text().includes('E+04 '))).toBe(true)
expect(wrapper.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__grid').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__plot-frame').exists()).toBe(false)
expect(wrapper.get('.waveform-chart__plot-frame').attributes()).toMatchObject({
stroke: '#dc2626',
'stroke-width': '2',
'stroke-dasharray': '6 4',
})
expect(wrapper.find('.waveform-chart__plot-background').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__watermark').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__legend-layer').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__label').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__y-axis-label').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__zero-line').exists()).toBe(false)
expect(wrapper.find('.waveform-annotation-layer').exists()).toBe(false)
expect(wrapper.find('.ant-pagination').exists()).toBe(false)

View File

@@ -241,7 +241,7 @@ describe('WaveformChart presentation mode', () => {
await flushPromises()
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -200,
deltaY: 200,
clientX: width / 2,
clientY: height / 2,
bubbles: true,

View File

@@ -38,6 +38,21 @@ describe('WaveformChart', () => {
expect(domain[1]).toBeLessThanOrEqual(1)
expect(endpoints()[0]).not.toBe(initialEndpoints[0])
expect(endpoints()[1]).toBe(initialEndpoints[1])
overlays[0].element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: 4_000,
clientX: firstWidth - 1,
clientY: 130,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
expect(endpoints()[0]).toBe(initialEndpoints[0])
expect(endpoints()[1]).toBe(initialEndpoints[1])
})
it('emits one zoom-end payload after a shared zoom gesture completes', async () => {
@@ -224,6 +239,56 @@ describe('WaveformChart', () => {
window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' }))
})
it('keeps a fixed Y domain through panning and viewport reset', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 100 },
],
},
{ pannable: true, yDomain: [3, 97] },
)
const overlay = wrapper.get('.waveform-chart__overlay--independent')
const width = Number(overlay.attributes('width'))
const height = Number(overlay.attributes('height'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height }),
})
const yTickLabels = () =>
wrapper
.get('.waveform-chart__axis--y')
.findAll('.tick text')
.map((tick) => tick.text())
const initialLabels = yTickLabels()
await wrapper.trigger('pointerenter')
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', cancelable: true }))
const down = new MouseEvent('pointerdown', {
button: 0,
clientX: width / 2,
clientY: height / 2,
bubbles: true,
})
Object.defineProperty(down, 'pointerId', { value: 34 })
overlay.element.dispatchEvent(down)
const move = new MouseEvent('pointermove', {
clientX: width / 2 + 20,
clientY: height / 2 + 40,
bubbles: true,
})
Object.defineProperty(move, 'pointerId', { value: 34 })
overlay.element.dispatchEvent(move)
await flushPromises()
expect(yTickLabels()).toEqual(initialLabels)
;(wrapper.vm as unknown as { resetViewport: () => void }).resetViewport()
await flushPromises()
expect(yTickLabels()).toEqual(initialLabels)
window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' }))
})
it('does not activate pannable on a chart that the pointer is outside', async () => {
const data: WaveformData = {
kind: 'points',

View File

@@ -79,8 +79,8 @@ describe('WaveformChart', () => {
expect(wrapper.emitted('zoom-change')).toBeUndefined()
expect(wrapper.emitted('zoom-end')).toBeUndefined()
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0.00')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
})
it('ignores independent viewport dragging', async () => {
@@ -207,7 +207,7 @@ describe('WaveformChart', () => {
expect(secondDomain[1] - secondDomain[0]).toBeCloseTo(10)
})
it('keeps one global domain while loading narrower and wider windows', async () => {
it('zooms a shared viewport back out after loading a narrower data window', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
@@ -256,9 +256,57 @@ describe('WaveformChart', () => {
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
await dispatchWheel(4000)
const currentDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
const zoomedOutDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount + 1)
expect(zoomedOutDomain[1] - zoomedOutDomain[0]).toBeCloseTo(100)
const fullDomainEventCount = wrapper.emitted('zoom-change')?.length ?? 0
await dispatchWheel(4000)
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(fullDomainEventCount)
})
it('allows zooming out when the minimum visible point count blocks zooming in', async () => {
const wrapper = await mountSizedChart({
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 10 },
{ x: 2, y: 20 },
],
})
const overlay = wrapper.get('.waveform-chart__overlay--independent')
const width = Number(overlay.attributes('width'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height: 290 }),
})
const dispatchWheel = async (deltaY: number) => {
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY,
clientX: width / 2,
clientY: 145,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
}
await dispatchWheel(-4000)
const zoomedDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
expect(zoomedDomain[1] - zoomedDomain[0]).toBeCloseTo(0.05)
await wrapper.setProps({ minVisiblePoints: 10 })
await flushPromises()
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
await dispatchWheel(-1000)
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount)
expect(currentDomain[1] - currentDomain[0]).toBeCloseTo(2.5)
await dispatchWheel(4000)
const zoomedOutDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount + 1)
expect(zoomedOutDomain).toEqual([0, 2])
})
it('resets a shared viewport on double-click and emits zoom-reset', async () => {
@@ -285,14 +333,14 @@ describe('WaveformChart', () => {
)
flushAnimationFrames()
await flushPromises()
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe('0.00')
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe('0')
overlay.element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
await flushPromises()
expect(wrapper.emitted('zoom-reset')).toHaveLength(1)
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0.00')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
})
it('exposes resetViewport for independent tracks', async () => {
@@ -316,13 +364,13 @@ describe('WaveformChart', () => {
)
flushAnimationFrames()
await flushPromises()
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).not.toBe('0.00')
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).not.toBe('0')
const chart = wrapper.vm as unknown as { resetViewport: () => void }
chart.resetViewport()
await flushPromises()
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0.00')
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1.00')
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0')
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1000')
})
})

View File

@@ -0,0 +1,114 @@
import { flushPromises } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { flushAnimationFrames } from '../../test/setup'
import { mountSizedChart } from '../../test/waveformChart'
describe('WaveformChart wheel zoom out', () => {
it('restores an independent initial domain after replacing data with a narrow window', async () => {
const createData = (start: number, end: number) => ({
kind: 'series' as const,
series: [
{
id: 'channel-a',
name: '通道 A',
data: {
kind: 'points' as const,
points: [
{ x: start, y: 0 },
{ x: end, y: 1 },
],
},
},
],
})
const wrapper = await mountSizedChart(createData(-5, 5), {
displayMode: 'independent',
initialXDomain: [-5, 5],
})
const overlay = wrapper.get('.waveform-chart__overlay--independent')
const overlayWidth = Number(overlay.attributes('width'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width: overlayWidth, height: 290 }),
})
const dispatchWheel = async (deltaY: number) => {
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY,
clientX: overlayWidth / 2,
clientY: 145,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
}
const endpoints = () => ({
start: wrapper.get('.waveform-chart__axis-endpoint--start').text(),
end: wrapper.get('.waveform-chart__axis-endpoint--end').text(),
})
expect(endpoints()).toEqual({ start: '-5000', end: '5000' })
await dispatchWheel(-4000)
expect(endpoints()).not.toEqual({ start: '-5000', end: '5000' })
await wrapper.setProps({ data: createData(-0.125, 0.125) })
await flushPromises()
await dispatchWheel(4000)
expect(endpoints()).toEqual({ start: '-5000', end: '5000' })
})
it('emits one zoom-end payload after zooming a shared viewport out', async () => {
vi.useFakeTimers()
try {
const wrapper = await mountSizedChart({
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 2, y: 1 },
],
})
const overlay = wrapper.get('.waveform-chart__overlay')
const overlayWidth = Number(overlay.attributes('width'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width: overlayWidth, height: 290 }),
})
const dispatchWheel = (deltaY: number) =>
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY,
clientX: overlayWidth / 2,
clientY: 145,
bubbles: true,
cancelable: true,
}),
)
dispatchWheel(-4000)
flushAnimationFrames()
await vi.advanceTimersByTimeAsync(200)
await flushPromises()
const zoomedDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
const zoomedSpan = zoomedDomain[1] - zoomedDomain[0]
dispatchWheel(1000)
flushAnimationFrames()
expect(wrapper.emitted('zoom-end')).toHaveLength(1)
await vi.advanceTimersByTimeAsync(200)
await flushPromises()
const zoomedOutPayload = wrapper.emitted('zoom-end')?.at(-1)?.[0] as {
start: number
end: number
gesture: string
}
expect(wrapper.emitted('zoom-end')).toHaveLength(2)
expect(zoomedOutPayload.end - zoomedOutPayload.start).toBeGreaterThan(zoomedSpan)
expect(zoomedOutPayload.gesture).toBe('wheel')
} finally {
vi.useRealTimers()
}
})
})

View File

@@ -96,6 +96,76 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
<Switch v-model:checked="model.yAxisLineVisible" size="small" aria-label="显示纵轴线" />
</div>
</div>
<div class="x-axis-label-controls">
<label class="frame-style-control frame-style-control--switch">
<span>自定义 Label</span>
<Switch
v-model:checked="model.xAxisLabelFormatterEnabled"
size="small"
aria-label="自定义 X Label"
/>
</label>
<label v-if="model.xAxisLabelFormatterEnabled" class="frame-style-control">
<span>格式类型</span>
<Select
v-model:value="model.xAxisLabelFormat"
:options="model.xAxisLabelFormatOptions"
size="small"
aria-label="X Label 格式类型"
/>
</label>
<label
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'number'"
class="frame-style-control"
>
<span>运算倍率</span>
<InputNumber
v-model:value="model.xAxisLabelMultiplier"
:min="-1000000"
:max="1000000"
:step="0.1"
size="small"
aria-label="X Label 运算倍率"
/>
</label>
<label
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'number'"
class="frame-style-control"
>
<span>小数位数</span>
<InputNumber
v-model:value="model.xAxisLabelFractionDigits"
:min="0"
:max="12"
:step="1"
size="small"
aria-label="X Label 小数位数"
/>
</label>
<label
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'datetime'"
class="frame-style-control"
>
<span>时区</span>
<Select
v-model:value="model.xAxisLabelTimeZone"
:options="model.xAxisLabelTimeZoneOptions"
size="small"
aria-label="X Label 时区"
/>
</label>
<label
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'datetime'"
class="frame-style-control frame-style-control--switch"
>
<span>显示毫秒</span>
<Switch
v-model:checked="model.xAxisLabelShowMilliseconds"
size="small"
aria-label="X Label 显示毫秒"
/>
</label>
</div>
</section>
<section class="control-section">
<h2>图框样式</h2>

View File

@@ -14,6 +14,7 @@ import type {
WaveformZeroLineOptions,
WaveformZoomEndPayload,
} from '../components'
import type { DemoXAxisLabelFormat, DemoXAxisLabelTimeZone } from './useDemoXAxisLabelControls'
interface SelectOption<T> {
label: string
@@ -44,6 +45,14 @@ export interface DemoControlPanelModel {
verticalGridColor: string
xAxisLineVisible: boolean
yAxisLineVisible: boolean
xAxisLabelFormatterEnabled: boolean
xAxisLabelFormat: DemoXAxisLabelFormat
xAxisLabelFormatOptions: Array<SelectOption<DemoXAxisLabelFormat>>
xAxisLabelMultiplier: number
xAxisLabelFractionDigits: number
xAxisLabelTimeZone: DemoXAxisLabelTimeZone
xAxisLabelTimeZoneOptions: Array<SelectOption<DemoXAxisLabelTimeZone>>
xAxisLabelShowMilliseconds: boolean
frameBorderColor: string
frameBackgroundColor: string
frameBorderWidth: number

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

197
src/demoRoutes.css Normal file
View File

@@ -0,0 +1,197 @@
.demo-shell {
display: grid;
grid-template-rows: 48px minmax(0, 1fr);
width: 100%;
height: 100%;
overflow: hidden;
}
.demo-shell__header {
z-index: 40;
display: flex;
gap: 24px;
align-items: center;
min-width: 0;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid #e4e7ec;
}
.demo-shell__brand {
flex: 0 0 auto;
color: #101828;
font-size: 14px;
font-weight: 700;
text-decoration: none;
}
.demo-shell__nav {
display: flex;
align-self: stretch;
min-width: 0;
}
.demo-shell__nav a {
display: inline-flex;
align-items: center;
padding: 0 14px;
color: #475467;
font-size: 13px;
text-decoration: none;
border-bottom: 2px solid transparent;
}
.demo-shell__nav a:hover,
.demo-shell__nav a:focus-visible,
.demo-shell__nav a.router-link-exact-active {
color: #0958d9;
}
.demo-shell__nav a.router-link-exact-active {
background: #f0f7ff;
border-bottom-color: #1677ff;
}
.demo-shell__content {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.fixed-domain-demo {
display: grid;
grid-template-rows: auto auto minmax(0, 1fr);
gap: 12px;
width: 100%;
height: 100%;
padding: 16px;
overflow: hidden;
}
.fixed-domain-demo__toolbar {
display: flex;
gap: 24px;
align-items: center;
justify-content: space-between;
min-width: 0;
}
.fixed-domain-demo__toolbar h1 {
margin: 0;
color: #101828;
font-size: 18px;
font-weight: 700;
letter-spacing: 0;
}
.fixed-domain-demo__toolbar p {
margin: 4px 0 0;
color: #667085;
font-size: 12px;
}
.fixed-domain-demo__range-controls {
display: flex;
gap: 16px;
align-items: center;
min-height: 48px;
padding: 8px 12px;
background: #fff;
border: 1px solid #e4e7ec;
border-radius: 6px;
}
.fixed-domain-demo__range-controls label {
display: flex;
gap: 8px;
align-items: center;
color: #475467;
font-size: 12px;
}
.fixed-domain-demo__range-controls .ant-input-number {
width: 112px;
}
.fixed-domain-demo__range-controls strong {
color: #344054;
font-size: 12px;
}
.fixed-domain-demo__range-controls strong:not(:first-child) {
padding-left: 16px;
border-left: 1px solid #e4e7ec;
}
.fixed-domain-demo__auto-state {
color: #667085;
font-size: 12px;
}
.fixed-domain-demo__chart {
min-width: 0;
min-height: 0;
overflow: hidden;
}
.fixed-domain-demo__chart > .waveform-chart {
min-height: 0;
}
@media (max-width: 900px) {
.demo-shell {
grid-template-rows: 44px minmax(0, 1fr);
}
.demo-shell__header {
gap: 8px;
padding: 0 8px;
}
.demo-shell__brand {
display: none;
}
.demo-shell__nav {
width: 100%;
}
.demo-shell__nav a {
flex: 1 1 0;
justify-content: center;
padding: 0 6px;
}
.fixed-domain-demo {
gap: 8px;
padding: 10px;
}
.fixed-domain-demo__toolbar {
display: grid;
gap: 8px;
}
.fixed-domain-demo__toolbar .ant-radio-group {
display: flex;
width: 100%;
}
.fixed-domain-demo__toolbar .ant-radio-button-wrapper {
flex: 1 1 0;
padding-inline: 6px;
text-align: center;
}
.fixed-domain-demo__range-controls {
flex-wrap: wrap;
gap: 8px 12px;
}
.fixed-domain-demo__range-controls strong:not(:first-child) {
width: 100%;
padding: 8px 0 0;
border-top: 1px solid #e4e7ec;
border-left: 0;
}
}

View File

@@ -23,6 +23,9 @@ export type {
WaveformLegendOrientation,
WaveformLegendOptions,
WaveformFrameStyle,
WaveformXAxisLabelKind,
WaveformXAxisLabelFormatterContext,
WaveformXAxisLabelFormatter,
WaveformAxesOptions,
WaveformZeroLineOptions,
// 数据类型

View File

@@ -1,7 +1,9 @@
import { createApp } from 'vue'
import 'ant-design-vue/dist/antd.css'
import App from './App.vue'
import DemoRouterApp from './DemoRouterApp.vue'
import { router } from './router'
import './demoRoutes.css'
import './styles.css'
createApp(App).mount('#app')
createApp(DemoRouterApp).use(router).mount('#app')

21
src/router.ts Normal file
View File

@@ -0,0 +1,21 @@
import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router'
import App from './App.vue'
export const routes: RouteRecordRaw[] = [
{
path: '/',
name: 'workspace',
component: App,
},
{
path: '/fixed-y-domain',
name: 'fixed-y-domain',
component: () => import('./views/FixedYDomainDemo.vue'),
},
]
export const router = createRouter({
history: createWebHashHistory(),
routes,
})

View File

@@ -35,8 +35,7 @@ body {
grid-template-columns: 280px minmax(0, 1fr);
gap: 12px;
width: 100%;
height: 100vh;
height: 100dvh;
height: 100%;
padding: 12px;
overflow: hidden;
}
@@ -134,6 +133,14 @@ body {
grid-template-columns: minmax(0, 1fr) auto;
}
.x-axis-label-controls {
display: grid;
gap: 10px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #eaecf0;
}
.grid-line-color-picker,
.grid-line-color-picker .vc-color-wrap {
width: 48px;

View File

@@ -121,10 +121,31 @@ export interface WaveformFrameStyle {
backgroundColor?: string
}
export type WaveformXAxisLabelKind = 'tick' | 'start' | 'end'
/** Context passed to custom X-axis label formatters. */
export interface WaveformXAxisLabelFormatterContext {
kind: WaveformXAxisLabelKind
/** X coordinate in the source data, before time-unit conversion. */
rawValue: number
timeUnit: 's' | 'ms'
/** Current visible X domain in source coordinates. */
domain: [number, number]
/** Current visible X domain converted to the selected display unit. */
displayDomain: [number, number]
}
export type WaveformXAxisLabelFormatter = (
value: number,
context: WaveformXAxisLabelFormatterContext,
) => string
/** Controls axis baseline visibility while preserving tick marks and axis text. */
export interface WaveformAxesOptions {
x?: {
lineVisible?: boolean
/** Formats display-unit X values for ticks and visible-range endpoints. */
labelFormatter?: WaveformXAxisLabelFormatter
}
y?: {
lineVisible?: boolean

View File

@@ -18,6 +18,9 @@ export type {
WaveformLegendOrientation,
WaveformLegendOptions,
WaveformFrameStyle,
WaveformXAxisLabelKind,
WaveformXAxisLabelFormatterContext,
WaveformXAxisLabelFormatter,
WaveformAxesOptions,
WaveformZeroLineOptions,
} from './chart'

View File

@@ -1,71 +1,112 @@
import { describe, expect, it } from 'vitest'
import type { WaveformXAxisLabelFormatter, WaveformXAxisLabelFormatterContext } from '../types'
import {
formatAnnotationTime,
formatAxisTime,
formatAxisTimeExponent,
formatEndpointTime,
formatPlainNumber,
formatScientificAxisExponent,
formatScientificAxisLabel,
formatTooltipNumber,
formatTooltipTime,
formatXAxisLabel,
resolveScientificAxisExponent,
shouldUseScientificAxisLabel,
} from './formatters'
describe('waveform number formatters', () => {
it('uses the reference Y-axis scientific notation boundaries', () => {
expect(shouldUseScientificAxisLabel(0)).toBe(false)
expect(shouldUseScientificAxisLabel(0.009)).toBe(true)
expect(shouldUseScientificAxisLabel(0.01)).toBe(false)
expect(shouldUseScientificAxisLabel(99.99)).toBe(false)
expect(shouldUseScientificAxisLabel(100)).toBe(true)
expect(shouldUseScientificAxisLabel(0.000999)).toBe(true)
expect(shouldUseScientificAxisLabel(0.001)).toBe(false)
expect(shouldUseScientificAxisLabel(999.999)).toBe(false)
expect(shouldUseScientificAxisLabel(1000)).toBe(true)
})
it('shares one separate exponent across an axis', () => {
const positiveAxis = { axisMin: 0, axisMax: 254 }
expect(formatScientificAxisLabel(127, positiveAxis)).toBe('1.27')
expect(formatScientificAxisLabel(254, positiveAxis)).toBe('2.54')
expect(formatScientificAxisExponent(0, 254)).toBe('E+02')
it('prefixes one shared exponent to the largest visible tick', () => {
const positiveAxis = { axisMin: 1000, axisMax: 3000 }
expect(formatScientificAxisLabel(1000, positiveAxis)).toBe('1')
expect(formatScientificAxisLabel(2000, positiveAxis)).toBe('2')
expect(formatScientificAxisLabel(3000, positiveAxis)).toBe('E+03 3')
expect(formatScientificAxisLabel(2000, { ...positiveAxis, topTickValue: 2000 })).toBe('E+03 2')
expect(formatScientificAxisExponent(1000, 3000)).toBe('E+03')
expect(formatScientificAxisExponent(0, 1e120)).toBe('E+120')
const tinyAxis = { axisMin: 0, axisMax: 0.0002 }
expect(formatScientificAxisLabel(0.0001, tinyAxis)).toBe('1.00')
expect(formatScientificAxisLabel(0.0002, tinyAxis)).toBe('2.00')
expect(formatScientificAxisExponent(0, 0.0002)).toBe('E-04')
const negativeAxis = { axisMin: -254, axisMax: 0 }
expect(formatScientificAxisLabel(-254, negativeAxis)).toBe('-2.54')
expect(formatScientificAxisLabel(0, negativeAxis)).toBe('0.00')
expect(formatScientificAxisExponent(-254, 0)).toBe('E+02')
const tinyAxis = { axisMin: 0.0001, axisMax: 0.0003 }
expect(formatScientificAxisLabel(0.0001, tinyAxis)).toBe('1')
expect(formatScientificAxisLabel(0.0002, tinyAxis)).toBe('2')
expect(formatScientificAxisLabel(0.0003, tinyAxis)).toBe('E-04 3')
expect(formatScientificAxisExponent(0.0001, 0.0003)).toBe('E-04')
})
it('keeps plain axes at two decimals and removes negative zero', () => {
expect(formatScientificAxisLabel(99.99, { axisMin: 0, axisMax: 99.99 })).toBe('99.99')
expect(formatScientificAxisLabel(0.01, { axisMin: 0, axisMax: 0.01 })).toBe('0.01')
expect(formatScientificAxisLabel(-0.001, { axisMin: -1, axisMax: 1 })).toBe('0.00')
it('derives the exponent from Math.max(axisMin, axisMax)', () => {
expect(resolveScientificAxisExponent(-9000, -1000)).toBe(3)
expect(resolveScientificAxisExponent(-10_000, 3000)).toBe(3)
expect(resolveScientificAxisExponent(-1000, 0)).toBeNull()
expect(resolveScientificAxisExponent(0, 0)).toBeNull()
})
it('keeps five significant digits, removes trailing zeroes, and limits decimals to four', () => {
expect(formatScientificAxisLabel(123.456, { axisMin: 0, axisMax: 999 })).toBe('123.46')
expect(formatScientificAxisLabel(1234.56, { axisMin: 0, axisMax: 9999 })).toBe('1.2346')
expect(formatScientificAxisLabel(3000, { axisMin: 1000, axisMax: 3000 })).toBe('E+03 3')
expect(formatScientificAxisLabel(-0, { axisMin: -1, axisMax: 1 })).toBe('0')
expect(formatScientificAxisLabel(Number.NaN)).toBe('NaN')
expect(formatScientificAxisLabel(Number.POSITIVE_INFINITY)).toBe('Infinity')
expect(formatScientificAxisExponent(0, 0)).toBeNull()
})
it('formats X-axis ticks and endpoints from the selected display unit', () => {
it('formats X-axis ticks and endpoints as complete plain values in the display unit', () => {
const domain: [number, number] = [0, 1]
expect(formatAxisTime(0.5, 'ms', domain)).toBe('0.50')
expect(formatEndpointTime(1, domain, 'ms')).toBe('1.00')
expect(formatAxisTimeExponent(domain, 'ms')).toBe('E+03')
expect(formatAxisTime(0.5, 's', domain)).toBe('0.50')
expect(formatEndpointTime(1, domain, 's')).toBe('1.00')
expect(formatAxisTimeExponent(domain, 's')).toBeNull()
expect(formatAxisTime(0.5004, 'ms', domain)).toBe('500.4')
expect(formatEndpointTime(1, domain, 'ms')).toBe('1000')
expect(formatAxisTime(0.5, 's', domain)).toBe('0.5')
expect(formatEndpointTime(1, domain, 's')).toBe('1')
const tinyDomain: [number, number] = [0, 0.000001]
expect(formatEndpointTime(0.000001, tinyDomain, 's')).toBe('1.00')
expect(formatAxisTimeExponent(tinyDomain, 's')).toBe('E-06')
expect(formatEndpointTime(0.000001, tinyDomain, 's')).toBe('0.000001')
expect(formatAxisTime(-0.000001, 's', tinyDomain)).toBe('-0.000001')
expect(formatAxisTime(-0, 's')).toBe('0')
expect(formatAxisTime(1e21, 's')).toBe('1000000000000000000000')
expect(formatAxisTime(Number.POSITIVE_INFINITY, 's')).toBe('Infinity')
})
it('passes display values and complete source context to X-axis label formatters', () => {
const domain: [number, number] = [0.125, 1.875]
const formatter: WaveformXAxisLabelFormatter = (value, context) =>
`${context.kind}:${value / 10}`
expect(formatXAxisLabel(0.5, domain, 'ms', 'tick', formatter)).toBe('tick:50')
expect(formatXAxisLabel(domain[0], domain, 'ms', 'start', formatter)).toBe('start:12.5')
expect(formatXAxisLabel(domain[1], domain, 'ms', 'end', formatter)).toBe('end:187.5')
let receivedContext: WaveformXAxisLabelFormatterContext | undefined
formatXAxisLabel(0.5, domain, 'ms', 'tick', (value, context) => {
receivedContext = context
return String(value)
})
expect(receivedContext).toEqual({
kind: 'tick',
rawValue: 0.5,
timeUnit: 'ms',
domain: [0.125, 1.875],
displayDomain: [125, 1875],
})
expect(() =>
formatXAxisLabel(0.5, domain, 's', 'tick', () => {
throw new Error('formatter failed')
}),
).toThrow('formatter failed')
})
it('formats tooltip and raw values for their display contexts', () => {
expect(formatTooltipNumber(12345.67891)).toBe('12,345.6789')
expect(formatTooltipNumber(-0)).toBe('0')
expect(formatTooltipNumber(Number.POSITIVE_INFINITY)).toBe('Infinity')
expect(formatTooltipTime(1.234567, 's')).toBe('1.2346')
expect(formatTooltipTime(1, 'ms')).toBe('1,000')
expect(formatPlainNumber(0.0000001)).toBe('0.0000001')
expect(formatPlainNumber(1e21)).toBe('1000000000000000000000')
expect(formatPlainNumber(-0)).toBe('0')

View File

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

View File

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

View File

@@ -0,0 +1,44 @@
import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { WaveformChart } from '../components'
import { routes } from '../router'
import FixedYDomainDemo from './FixedYDomainDemo.vue'
describe('fixed Y-domain demo route', () => {
it('registers the example route', () => {
expect(routes).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: '/fixed-y-domain',
name: 'fixed-y-domain',
}),
]),
)
})
it('switches between global, automatic, and per-channel ranges', async () => {
const wrapper = mount(FixedYDomainDemo)
await flushPromises()
const chart = () => wrapper.getComponent(WaveformChart)
const modeInputs = wrapper.get('[aria-label="Y 轴范围模式"]').findAll('input[type="radio"]')
expect(chart().props('yDomain')).toEqual([-80, 80])
expect(chart().props('yDomains')).toBeUndefined()
await modeInputs[0]?.setValue(true)
await flushPromises()
expect(chart().props('yDomain')).toBeUndefined()
expect(chart().props('yDomains')).toBeUndefined()
await modeInputs[2]?.setValue(true)
await flushPromises()
expect(chart().props('yDomain')).toBeUndefined()
expect(chart().props('yDomains')).toEqual({
voltage: [-65, 65],
current: [-260, 260],
})
wrapper.unmount()
})
})

View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import { InputNumber, RadioButton, RadioGroup } from 'ant-design-vue'
import { WaveformChart, type WaveformData } from '../components'
type RangeMode = 'auto' | 'global' | 'channel'
const rangeMode = ref<RangeMode>('global')
const globalMinimum = ref(-80)
const globalMaximum = ref(80)
const voltageMinimum = ref(-65)
const voltageMaximum = ref(65)
const currentMinimum = ref(-260)
const currentMaximum = ref(260)
const chartData: WaveformData = {
kind: 'series',
series: [
{
id: 'voltage',
name: '电压',
unit: 'V',
color: '#1677ff',
data: {
kind: 'points',
points: Array.from({ length: 800 }, (_, index) => {
const x = index / 80
const pulse = index % 240 >= 112 && index % 240 <= 122 ? 58 : 0
return { x, y: 48 * Math.sin(x * Math.PI * 1.5) + pulse }
}),
},
},
{
id: 'current',
name: '电流',
unit: 'mA',
color: '#d4380d',
data: {
kind: 'points',
points: Array.from({ length: 800 }, (_, index) => {
const x = index / 80
const pulse = index % 300 >= 184 && index % 300 <= 192 ? -210 : 0
return { x, y: 185 * Math.cos(x * Math.PI) + pulse }
}),
},
},
],
}
function orderedDomain(minimum: number, maximum: number): [number, number] | undefined {
if (!Number.isFinite(minimum) || !Number.isFinite(maximum) || minimum === maximum) {
return undefined
}
return minimum < maximum ? [minimum, maximum] : [maximum, minimum]
}
const yDomain = computed<[number, number] | undefined>(() =>
rangeMode.value === 'global'
? orderedDomain(globalMinimum.value, globalMaximum.value)
: undefined,
)
const yDomains = computed<Record<string, [number, number]> | undefined>(() => {
if (rangeMode.value !== 'channel') return undefined
const voltage = orderedDomain(voltageMinimum.value, voltageMaximum.value)
const current = orderedDomain(currentMinimum.value, currentMaximum.value)
return {
...(voltage ? { voltage } : {}),
...(current ? { current } : {}),
}
})
const rangeSummary = computed(() => {
if (rangeMode.value === 'auto') return '自动计算'
if (rangeMode.value === 'global') {
return yDomain.value ? `${yDomain.value[0]} ${yDomain.value[1]}` : '自动计算'
}
const voltage = yDomains.value?.voltage
const current = yDomains.value?.current
return `电压 ${voltage?.[0] ?? '-'} ${voltage?.[1] ?? '-'} V · 电流 ${
current?.[0] ?? '-'
} ${current?.[1] ?? '-'} mA`
})
</script>
<template>
<main class="fixed-domain-demo">
<header class="fixed-domain-demo__toolbar">
<div>
<h1>固定振幅范围</h1>
<p>当前范围{{ rangeSummary }}</p>
</div>
<RadioGroup v-model:value="rangeMode" button-style="solid" aria-label="Y 轴范围模式">
<RadioButton value="auto">自动</RadioButton>
<RadioButton value="global">全局固定</RadioButton>
<RadioButton value="channel">按通道固定</RadioButton>
</RadioGroup>
</header>
<section class="fixed-domain-demo__range-controls">
<template v-if="rangeMode === 'global'">
<label>
<span>下限</span>
<InputNumber v-model:value="globalMinimum" aria-label="全局振幅下限" />
</label>
<label>
<span>上限</span>
<InputNumber v-model:value="globalMaximum" aria-label="全局振幅上限" />
</label>
</template>
<template v-else-if="rangeMode === 'channel'">
<strong>电压</strong>
<label>
<span>下限</span>
<InputNumber v-model:value="voltageMinimum" aria-label="电压振幅下限" />
</label>
<label>
<span>上限</span>
<InputNumber v-model:value="voltageMaximum" aria-label="电压振幅上限" />
</label>
<strong>电流</strong>
<label>
<span>下限</span>
<InputNumber v-model:value="currentMinimum" aria-label="电流振幅下限" />
</label>
<label>
<span>上限</span>
<InputNumber v-model:value="currentMaximum" aria-label="电流振幅上限" />
</label>
</template>
<span v-else class="fixed-domain-demo__auto-state">根据可见数据自动计算</span>
</section>
<section class="fixed-domain-demo__chart">
<WaveformChart
:data="chartData"
:y-domain="yDomain"
:y-domains="yDomains"
display-mode="independent"
:grid="{ rowCount: 2, columnCount: 1, showPagination: false }"
:title="{ visible: true, text: '振幅上下限示例', align: 'left' }"
:zero-line="{ visible: true, color: '#98a2b3', width: 1, dash: '5 4' }"
:legend="{ position: 'top-right', backgroundColor: 'rgba(255,255,255,0.8)' }"
x-label="时间s"
time-unit="s"
:show-tooltip="true"
/>
</section>
</main>
</template>