8 Commits

Author SHA1 Message Date
李启源
7646bf4907 merge: integrate hybrid domain architecture 2026-08-07 15:59:38 +08:00
李启源
e2725e5569 refactor(chart): introduce hybrid domain architecture 2026-08-07 15:59:26 +08:00
李启源
9eb1f0f137 feat(chart): support configurable plot margins
All checks were successful
Package component / package (push) Successful in 7m2s
2026-08-05 15:16:52 +08:00
李启源
fbf10fdb88 feat(chart): support configurable x-axis labels
All checks were successful
Package component / package (push) Successful in 4m35s
2026-08-05 10:58:40 +08:00
李启源
c5aa3c662a fix(chart): refine axis number formatting
All checks were successful
Package component / package (push) Successful in 4m2s
2026-08-04 11:28:42 +08:00
李启源
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
65 changed files with 2461 additions and 456 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` 为固定振幅范围示例
## 本地开发

36
docs/architecture.md Normal file
View File

@@ -0,0 +1,36 @@
# Architecture
Waveform Analysis uses a hybrid architecture. Vue Composition API remains the orchestration
boundary, while classes are reserved for domain objects with lifecycle or algorithm-selection
invariants.
## Boundaries
- `useWaveformChartController` is the facade for the chart. It composes composables and exposes the
existing reactive controller surface; it owns no independent copy of component props.
- `useWaveformViewport` keeps refs, computed values, pointer events, DOM capture, D3 coordinates,
and emitted events. `ViewportInteractionStateMachine` is its domain collaborator: it has no Vue
or DOM dependency and accepts only legal `begin`, `move`, `finish`, `cancel`, and `reset`
transitions. Its state is a `box`/`pan` discriminated union, with `null` representing idle.
- `RenderablePointSelectionStrategy` defines the replaceable rendering algorithm boundary.
`CompletePointSelectionStrategy` preserves the complete visible source range and
`PeakPreservingPointSelectionStrategy` preserves first/minimum/maximum/last points per bucket.
`resolveRenderablePointSelectionStrategy` resolves and reuses the strategy from rendering options. The
existing `selectRenderablePoints` function remains the compatibility facade used by rendering.
- `normalizeWaveformData` and `normalizeWaveformSeries` are functional adapters from public data
shapes to the internal series model. `buildTrackLayouts` remains a functional builder because
layout construction is a stateless calculation, not a long-lived object.
## Vue Integration
The state-machine instance is stored in `shallowRef(markRaw(...))`. Vue receives defensive state
snapshots through a shallow ref, while SVG overlay elements remain in the composable as DOM
resources. Presentation mode, annotation editing, scales, domains, ticks, formatting, and other
stateless calculations stay in their existing computed/composable or function boundaries.
## Constraints
Do not create classes solely to wrap Composition API refs, props, lifecycle hooks, D3 selections, or
pure mathematical helpers. Do not add inheritance trees, global event buses, service locators, or
duplicate prop state. A new class must own a real invariant or replaceable algorithm and must be
used by a production path with isolated tests.

View File

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

@@ -14,6 +14,7 @@ import {
type WaveformLegendOrientation,
type WaveformLegendPosition,
type WaveformOverlayMode,
type WaveformPlotMargin,
type WaveformTitleOptions,
type WaveformZoomEndPayload,
type WaveformZeroLineOptions,
@@ -23,6 +24,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,11 +42,14 @@ 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)
const presentationMode = ref(false)
const showTooltip = ref(true)
const plotMarginTop = ref(18)
const plotMarginBottom = ref(52)
const zeroLineVisible = ref(false)
const zeroLineColor = ref('#98a2b3')
const zeroLineWidth = ref(1)
@@ -112,7 +117,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>(() => ({
@@ -121,6 +129,10 @@ const zeroLine = computed<WaveformZeroLineOptions>(() => ({
width: zeroLineWidth.value,
dash: zeroLineDash.value,
}))
const plotMargin = computed<WaveformPlotMargin>(() => ({
top: plotMarginTop.value,
bottom: plotMarginBottom.value,
}))
const initialXValues = normalizeWaveformSeries(fullChartData).flatMap((series) =>
series.points.map((point) => point.x),
@@ -289,6 +301,8 @@ const controlPanelModel = reactive({
displayMode,
overlayMode,
showTooltip,
plotMarginTop,
plotMarginBottom,
cleanView,
presentationMode,
selectedSeriesId,
@@ -308,6 +322,7 @@ const controlPanelModel = reactive({
verticalGridColor,
xAxisLineVisible,
yAxisLineVisible,
...xAxisLabelControlModel,
frameBorderColor,
frameBackgroundColor,
frameBorderWidth,
@@ -354,6 +369,7 @@ const chartModel = reactive({
cleanView,
presentationMode,
showTooltip,
plotMargin,
zeroLine,
frameWatermarkVisible,
annotations,

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

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

View File

@@ -19,6 +19,7 @@ const {
overlayMode,
resolvedChartLeftMargin,
titleAreaHeight,
resolvedPlotMargin,
pointerInsideChart,
handleNativeContextMenu,
titleAreaReserved,
@@ -109,7 +110,7 @@ const {
{
'waveform-chart--clean': isCleanView,
'waveform-chart--presentation': isPresentationMode,
'waveform-chart--panning': selection?.mode === 'pan',
'waveform-chart--panning': selection?.kind === 'pan',
},
]"
:style="containerStyle"
@@ -118,6 +119,8 @@ const {
:data-presentation-mode="isPresentationMode"
:data-overlay-mode="overlayMode"
:data-chart-left-margin="resolvedChartLeftMargin"
:data-plot-margin-top="resolvedPlotMargin.top"
:data-plot-margin-bottom="resolvedPlotMargin.bottom"
:data-title-area-height="titleAreaHeight"
@pointerenter="pointerInsideChart = true"
@pointerleave="pointerInsideChart = false"
@@ -244,7 +247,7 @@ const {
/>
<rect
v-if="selectionBox && selection?.mode === 'box'"
v-if="selectionBox && selection?.kind === 'box'"
class="waveform-chart__zoom-selection"
:x="selectionBox.x"
:y="selectionBox.y"
@@ -287,18 +290,18 @@ const {
/>
</g>
</g>
<text
v-if="resolvedXLabel && !isCleanView"
class="waveform-chart__label waveform-chart__x-label"
:x="innerWidth / 2"
:y="xAxisTitleY"
text-anchor="middle"
>
{{ resolvedXLabel }}
</text>
</g>
<text
v-if="resolvedXLabel && !isCleanView"
class="waveform-chart__label waveform-chart__x-label"
:x="resolvedChartLeftMargin + innerWidth / 2"
:y="xAxisTitleY"
text-anchor="middle"
>
{{ resolvedXLabel }}
</text>
<text
v-if="hasChartArea && !hasWaveformData"
class="waveform-chart__empty"

View File

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

View File

@@ -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,
@@ -17,6 +18,7 @@ import { useWaveformChartAnnotations } from '../annotation/useWaveformChartAnnot
import { useWaveformHover } from '../interaction/useWaveformHover'
import { useWaveformViewport } from '../interaction/useWaveformViewport'
import { useWaveformZoom } from '../interaction/useWaveformZoom'
import { ViewportInteractionStateMachine } from '../interaction/viewportInteractionState'
import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
import { margin } from './constants'
import { getPageSize } from './grid'
@@ -68,7 +70,17 @@ export function useWaveformChartController(
const annotationInteraction = useWaveformAnnotationInteraction()
const editorSeriesOptions = ref<AnnotationSeriesCandidate[]>([])
const hoverThrottle = useAnimationFrameThrottle()
const selection = ref<ViewportSelectionState | null>(null)
watch(
[() => props.yDomain, () => props.yDomains],
() => {
sharedYDomains.value = {}
independentYDomains.value = {}
},
{ deep: true },
)
const selection = shallowRef<ViewportSelectionState | null>(null)
const viewportInteraction = shallowRef(markRaw(new ViewportInteractionStateMachine()))
const spacePressed = ref(false)
const pointerInsideChart = ref(false)
let handleDataReferenceChange: () => void = () => undefined
@@ -183,6 +195,7 @@ export function useWaveformChartController(
props,
emit,
selection,
viewportInteraction,
spacePressed,
trackLayouts,
chartTracks,

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,
@@ -24,9 +24,13 @@ import {
normalizeGridOptions,
paginateSeries,
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 +108,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 +151,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 +272,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 +284,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,
@@ -321,7 +315,6 @@ export function useWaveformLayout(context: LayoutContext) {
)
: [],
)
const xAxisTitleY = computed(() => innerHeight.value + X_AXIS_BAND + 10)
const editorSeries = computed<AnnotationSeriesInfo | undefined>(() => {
const seriesId = annotationInteraction.editorDraft.value?.annotation.seriesId
const series = chartSeries.value.find((item) => item.id === seriesId)
@@ -365,7 +358,6 @@ export function useWaveformLayout(context: LayoutContext) {
resolveSeriesYScale,
annotationTrackLayouts,
renderedAnnotations,
xAxisTitleY,
editorSeries,
resolveFrameNumber,
}

View File

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

View File

@@ -7,6 +7,7 @@ import type {
WaveformInteractionMode,
WaveformLegendOptions,
WaveformOverlayMode,
WaveformPlotMargin,
WaveformPoint,
WaveformRenderingOptions,
WaveformTitleOptions,
@@ -31,6 +32,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
@@ -40,6 +43,7 @@ export interface WaveformChartProps {
interactionMode?: WaveformInteractionMode
grid?: WaveformGridOptions
rendering?: WaveformRenderingOptions
plotMargin?: WaveformPlotMargin
title?: WaveformTitleOptions
legend?: WaveformLegendOptions
hiddenSeriesIds?: string[]
@@ -63,6 +67,7 @@ type DefaultedProp =
| 'annotationsVisible'
| 'grid'
| 'rendering'
| 'plotMargin'
| 'legend'
| 'defaultHiddenSeriesIds'
| 'cleanView'
@@ -90,16 +95,17 @@ export interface WaveformChartEmit {
(event: 'page-change', page: number, pageCount: number): void
}
export interface ViewportSelectionState {
interface ViewportSelectionBase {
trackIndex: number
independent: boolean
overlay: SVGRectElement
startX: number
startY: number
currentX: number
currentY: number
pointerId: number
mode: 'box' | 'pan'
xDomain: [number, number]
yDomains: Record<string, [number, number]>
}
export type ViewportSelectionState =
(ViewportSelectionBase & { kind: 'box' }) | (ViewportSelectionBase & { kind: 'pan' })

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

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

View File

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

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

@@ -136,7 +136,7 @@ export function useWaveformHover(context: HoverContext) {
}
const handleSharedPointerMove = (event: PointerEvent) => {
if (isPresentationMode.value) return
if (selection.value?.overlay === event.currentTarget) {
if (selection.value && !selection.value.independent) {
updateViewportDrag(event)
return
}

View File

@@ -1,19 +1,22 @@
import { pointer, scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
import { computed, nextTick, type ComputedRef, type Ref, type ShallowRef } from 'vue'
import { computed, nextTick, shallowRef, type ComputedRef, type Ref, type ShallowRef } from 'vue'
import { MINIMUM_SELECTION_SIZE } from '../core/constants'
import type { DisplayTrack, TrackLayout } from '../core/types'
import { hasFixedYDomainForTrack } from '../core/yDomain'
import type {
ResolvedWaveformChartProps,
ViewportSelectionState,
WaveformChartEmit,
} from '../core/waveformChartTypes'
import type { AnnotationSeriesCandidate } from '../annotation'
import type { ViewportInteractionStateMachine } from './viewportInteractionState'
interface ViewportContext {
props: ResolvedWaveformChartProps
emit: WaveformChartEmit
selection: Ref<ViewportSelectionState | null>
viewportInteraction: ShallowRef<ViewportInteractionStateMachine>
spacePressed: Ref<boolean>
trackLayouts: ComputedRef<TrackLayout[]>
chartTracks: ComputedRef<DisplayTrack[]>
@@ -42,6 +45,7 @@ export function useWaveformViewport(context: ViewportContext) {
props,
emit,
selection,
viewportInteraction,
spacePressed,
trackLayouts,
chartTracks,
@@ -64,6 +68,10 @@ export function useWaveformViewport(context: ViewportContext) {
clearHover,
resolveTrackAtPointer,
} = context
const activeOverlay = shallowRef<SVGRectElement>()
const syncSelection = () => {
selection.value = viewportInteraction.value.state
}
const selectionBox = computed(() => {
const active = selection.value
if (!active) return null
@@ -142,19 +150,19 @@ export function useWaveformViewport(context: ViewportContext) {
const [rawX, rawY] = pointer(event, overlay)
const x = Math.max(0, Math.min(independent ? track.width : innerWidth.value, rawX))
const y = Math.max(0, Math.min(independent ? track.height : innerHeight.value, rawY))
selection.value = {
const started = viewportInteraction.value.begin({
trackIndex,
independent,
overlay,
startX: x,
startY: y,
currentX: x,
currentY: y,
pointerId: event.pointerId,
mode: panRequested ? 'pan' : 'box',
kind: panRequested ? 'pan' : 'box',
xDomain: track.xScale.domain() as [number, number],
yDomains: currentYDomains(),
}
})
if (!started) return
activeOverlay.value = overlay
syncSelection()
overlay.setPointerCapture?.(event.pointerId)
clearHover()
event.preventDefault()
@@ -190,9 +198,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],
@@ -208,27 +220,33 @@ export function useWaveformViewport(context: ViewportContext) {
const updateViewportDrag = (event: PointerEvent) => {
if (isPresentationMode.value) return
const active = selection.value
if (!active || event.pointerId !== active.pointerId) return
const overlay = activeOverlay.value
if (!active || !overlay || event.pointerId !== active.pointerId) return
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
if (!track) return
const [rawX, rawY] = pointer(event, active.overlay)
active.currentX = Math.max(
const [rawX, rawY] = pointer(event, overlay)
const currentX = Math.max(
0,
Math.min(active.independent ? track.width : innerWidth.value, rawX),
)
active.currentY = Math.max(
const currentY = Math.max(
0,
Math.min(active.independent ? track.height : innerHeight.value, rawY),
)
selection.value = { ...active }
if (active.mode === 'pan') applyPan(active, track)
const next = viewportInteraction.value.move(event.pointerId, { currentX, currentY })
if (!next) return
selection.value = next
if (next.kind === 'pan') applyPan(next, track)
event.preventDefault()
}
const cancelViewportDrag = (event?: PointerEvent) => {
const active = selection.value
if (!active || (event && event.pointerId !== active.pointerId)) return
active.overlay.releasePointerCapture?.(active.pointerId)
selection.value = null
activeOverlay.value?.releasePointerCapture?.(active.pointerId)
if (viewportInteraction.value.cancel(event?.pointerId)) {
activeOverlay.value = undefined
syncSelection()
}
}
const applyBoxZoom = (active: ViewportSelectionState) => {
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
@@ -295,15 +313,32 @@ export function useWaveformViewport(context: ViewportContext) {
}
const active = selection.value
if (!active || event.pointerId !== active.pointerId) return
updateViewportDrag(event)
active.overlay.releasePointerCapture?.(active.pointerId)
selection.value = null
if (active.mode === 'pan') {
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
if (!track) return
const overlay = activeOverlay.value
if (!overlay) return
const [rawX, rawY] = pointer(event, overlay)
const currentX = Math.max(
0,
Math.min(active.independent ? track.width : innerWidth.value, rawX),
)
const currentY = Math.max(
0,
Math.min(active.independent ? track.height : innerHeight.value, rawY),
)
const completed = viewportInteraction.value.finish(event.pointerId, { currentX, currentY })
if (!completed) return
overlay.releasePointerCapture?.(completed.pointerId)
activeOverlay.value = undefined
syncSelection()
event.preventDefault()
if (completed.kind === 'pan') {
applyPan(completed, track)
void nextTick(configureZoom)
return
}
if (Math.abs(active.currentX - active.startX) >= MINIMUM_SELECTION_SIZE) {
applyBoxZoom(active)
if (Math.abs(completed.currentX - completed.startX) >= MINIMUM_SELECTION_SIZE) {
applyBoxZoom(completed)
}
}
const resetViewport = (trackIndex?: number) => {

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

@@ -0,0 +1,76 @@
import { describe, expect, it } from 'vitest'
import { ViewportInteractionStateMachine } from './viewportInteractionState'
const gesture = {
trackIndex: 2,
independent: true,
startX: 10,
startY: 20,
pointerId: 7,
kind: 'box' as const,
xDomain: [0, 100] as [number, number],
yDomains: { track: [-1, 1] as [number, number] },
}
describe('ViewportInteractionStateMachine', () => {
it('accepts one gesture and rejects a conflicting start', () => {
const machine = new ViewportInteractionStateMachine()
expect(machine.begin(gesture)).toBe(true)
expect(machine.begin({ ...gesture, pointerId: 8 })).toBe(false)
expect(machine.state).toMatchObject({ kind: 'box', pointerId: 7 })
})
it('rejects moves and completion from a different pointer', () => {
const machine = new ViewportInteractionStateMachine()
machine.begin(gesture)
expect(machine.move(8, { currentX: 30, currentY: 40 })).toBeNull()
expect(machine.finish(8, { currentX: 30, currentY: 40 })).toBeNull()
expect(machine.state?.currentX).toBe(10)
})
it('updates a valid pointer, completes it, and returns to idle', () => {
const machine = new ViewportInteractionStateMachine()
machine.begin({ ...gesture, kind: 'pan' })
expect(machine.move(7, { currentX: 30, currentY: 40 })).toMatchObject({
kind: 'pan',
currentX: 30,
currentY: 40,
})
expect(machine.finish(7, { currentX: 50, currentY: 60 })).toMatchObject({
kind: 'pan',
currentX: 50,
currentY: 60,
})
expect(machine.state).toBeNull()
})
it('only cancels the owning pointer and supports explicit reset', () => {
const machine = new ViewportInteractionStateMachine()
machine.begin(gesture)
expect(machine.cancel(8)).toBe(false)
expect(machine.state).not.toBeNull()
expect(machine.cancel(7)).toBe(true)
expect(machine.state).toBeNull()
machine.begin(gesture)
machine.reset()
expect(machine.state).toBeNull()
})
it('returns defensive copies from its state getter', () => {
const machine = new ViewportInteractionStateMachine()
machine.begin(gesture)
const snapshot = machine.state!
snapshot.currentX = 99
snapshot.xDomain[0] = 50
snapshot.yDomains.track![0] = 50
expect(machine.state).toMatchObject({ currentX: 10, xDomain: [0, 100] })
expect(machine.state?.yDomains.track).toEqual([-1, 1])
})
})

View File

@@ -0,0 +1,80 @@
import type { ViewportSelectionState } from '../core/waveformChartTypes'
export interface ViewportGestureStart {
trackIndex: number
independent: boolean
startX: number
startY: number
pointerId: number
kind: 'box' | 'pan'
xDomain: [number, number]
yDomains: Record<string, [number, number]>
}
export interface ViewportGesturePosition {
currentX: number
currentY: number
}
function cloneState(state: ViewportSelectionState | null): ViewportSelectionState | null {
if (!state) return null
return {
...state,
xDomain: [...state.xDomain],
yDomains: Object.fromEntries(
Object.entries(state.yDomains).map(([key, domain]) => [key, [...domain] as [number, number]]),
),
}
}
/** Owns the legal lifecycle of one active viewport pointer gesture. */
export class ViewportInteractionStateMachine {
private current: ViewportSelectionState | null = null
get state(): ViewportSelectionState | null {
return cloneState(this.current)
}
begin(input: ViewportGestureStart): boolean {
if (this.current) return false
this.current = {
...input,
currentX: input.startX,
currentY: input.startY,
xDomain: [...input.xDomain],
yDomains: Object.fromEntries(
Object.entries(input.yDomains).map(([key, domain]) => [
key,
[...domain] as [number, number],
]),
),
}
return true
}
move(pointerId: number, position: ViewportGesturePosition): ViewportSelectionState | null {
if (!this.current || this.current.pointerId !== pointerId) return null
this.current = { ...this.current, ...position }
return this.state
}
finish(pointerId: number, position: ViewportGesturePosition): ViewportSelectionState | null {
if (!this.current || this.current.pointerId !== pointerId) return null
this.current = { ...this.current, ...position }
const completed = this.state
this.current = null
return completed
}
cancel(pointerId?: number): boolean {
if (!this.current || (pointerId !== undefined && this.current.pointerId !== pointerId)) {
return false
}
this.current = null
return true
}
reset(): void {
this.current = null
}
}

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

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

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

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

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

@@ -3,6 +3,10 @@ import { bisector } from 'd3'
import type { WaveformPoint } from '@/types'
import { resolveWaveformPointErrors } from './data'
import type { ResolvedWaveformRenderingOptions } from './renderingOptions'
import {
resolveRenderablePointSelectionStrategy,
type VisiblePointRange,
} from './renderingStrategies'
export {
DEFAULT_WAVEFORM_RENDERING_OPTIONS,
@@ -13,11 +17,6 @@ export {
const pointBisector = bisector((point: WaveformPoint) => point.x)
const acceptAllPoints = () => true
interface VisiblePointRange {
start: number
end: number
}
interface PointSeriesSource {
points: WaveformPoint[]
}
@@ -65,10 +64,6 @@ export function hasMinimumVisibleXValues(
return false
}
function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefined) {
if (point && target[target.length - 1] !== point) target.push(point)
}
function selectRenderablePointsInRange(
points: WaveformPoint[],
range: VisiblePointRange,
@@ -76,82 +71,17 @@ function selectRenderablePointsInRange(
width: number,
options: ResolvedWaveformRenderingOptions,
): WaveformPoint[] {
const domainStart = Math.min(domain[0], domain[1])
const domainEnd = Math.max(domain[0], domain[1])
const start = Math.max(0, range.start - 1)
const end = Math.min(points.length, range.end + 1)
const visibleCount = end - start
if (visibleCount <= 0) return []
if (!options.downsample || visibleCount <= options.downsampleThreshold) {
return points.slice(start, end)
}
const maximumPointCount = Math.max(4, Math.floor(width * options.maxPointsPerPixel))
const bucketCount = Math.max(1, Math.floor(maximumPointCount / 4))
if (visibleCount <= maximumPointCount) return points.slice(start, end)
const result: WaveformPoint[] = []
const span = domainEnd - domainStart || 1
const bucketIndexes = Array.from({ length: 4 }, () => -1)
let activeBucket = -1
let firstIndex = -1
let lastIndex = -1
let minimumIndex = -1
let maximumIndex = -1
const addBucketIndex = (index: number, count: number) => {
if (index < 0) return count
for (let position = 0; position < count; position += 1) {
if (bucketIndexes[position] === index) return count
}
bucketIndexes[count] = index
return count + 1
}
const flushBucket = () => {
if (firstIndex < 0) return
let count = 0
count = addBucketIndex(firstIndex, count)
count = addBucketIndex(minimumIndex, count)
count = addBucketIndex(maximumIndex, count)
count = addBucketIndex(lastIndex, count)
for (let index = 1; index < count; index += 1) {
const value = bucketIndexes[index]
let position = index - 1
while (position >= 0 && bucketIndexes[position] > value) {
bucketIndexes[position + 1] = bucketIndexes[position]
position -= 1
}
bucketIndexes[position + 1] = value
}
for (let index = 0; index < count; index += 1) {
pushUniquePoint(result, points[bucketIndexes[index]])
}
}
pushUniquePoint(result, points[start])
for (let index = range.start; index < range.end; index += 1) {
const point = points[index]
const bucket = Math.min(
bucketCount - 1,
Math.max(0, Math.floor(((point.x - domainStart) / span) * bucketCount)),
)
if (bucket !== activeBucket) {
flushBucket()
activeBucket = bucket
firstIndex = index
lastIndex = index
minimumIndex = index
maximumIndex = index
continue
}
lastIndex = index
if (point.y < points[minimumIndex].y) minimumIndex = index
if (point.y > points[maximumIndex].y) maximumIndex = index
}
flushBucket()
pushUniquePoint(result, points[end - 1])
return result
return resolveRenderablePointSelectionStrategy({ visibleCount, width, options }).select({
points,
range,
domain,
width,
options,
})
}
/**

View File

@@ -0,0 +1,78 @@
import { describe, expect, it } from 'vitest'
import type { WaveformPoint } from '../types'
import { DEFAULT_WAVEFORM_RENDERING_OPTIONS } from './renderingOptions'
import {
CompletePointSelectionStrategy,
PeakPreservingPointSelectionStrategy,
resolveRenderablePointSelectionStrategy,
} from './renderingStrategies'
const denseOptions = {
...DEFAULT_WAVEFORM_RENDERING_OPTIONS,
downsampleThreshold: 0,
maxPointsPerPixel: 1,
}
describe('renderable point selection strategies', () => {
it('resolves complete-point selection at the configured boundaries', () => {
const complete = resolveRenderablePointSelectionStrategy({
visibleCount: 100,
width: 100,
options: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
})
const disabled = resolveRenderablePointSelectionStrategy({
visibleCount: 10_000,
width: 100,
options: { ...denseOptions, downsample: false },
})
expect(complete).toBeInstanceOf(CompletePointSelectionStrategy)
expect(disabled).toBeInstanceOf(CompletePointSelectionStrategy)
})
it('resolves peak-preserving selection for dense visible data', () => {
const strategy = resolveRenderablePointSelectionStrategy({
visibleCount: 1_000,
width: 100,
options: denseOptions,
})
expect(strategy).toBeInstanceOf(PeakPreservingPointSelectionStrategy)
expect(strategy.name).toBe('peak-preserving')
})
it('reuses the resolved strategy instance across selections', () => {
const request = { visibleCount: 100, width: 100, options: DEFAULT_WAVEFORM_RENDERING_OPTIONS }
const peakRequest = { visibleCount: 1_000, width: 100, options: denseOptions }
expect(resolveRenderablePointSelectionStrategy(request)).toBe(
resolveRenderablePointSelectionStrategy(request),
)
expect(resolveRenderablePointSelectionStrategy(peakRequest)).toBe(
resolveRenderablePointSelectionStrategy(peakRequest),
)
})
it('retains first, last, minimum, and maximum points in a peak bucket', () => {
const points: WaveformPoint[] = [
{ x: 0, y: 5 },
{ x: 1, y: 1 },
{ x: 2, y: 10 },
{ x: 3, y: 3 },
{ x: 4, y: 7 },
]
const strategy = new PeakPreservingPointSelectionStrategy()
const selected = strategy.select({
points,
range: { start: 0, end: points.length },
domain: [0, 4],
width: 4,
options: denseOptions,
})
expect(selected[0]).toBe(points[0])
expect(selected.at(-1)).toBe(points.at(-1))
expect(selected.map((point) => point.y)).toEqual(expect.arrayContaining([1, 10]))
})
})

View File

@@ -0,0 +1,149 @@
import type { WaveformPoint } from '../types'
import type { ResolvedWaveformRenderingOptions } from './renderingOptions'
export interface VisiblePointRange {
start: number
end: number
}
export interface RenderablePointSelectionContext {
points: WaveformPoint[]
range: VisiblePointRange
domain: [number, number]
width: number
options: ResolvedWaveformRenderingOptions
}
export interface RenderablePointSelectionStrategy {
readonly name: 'complete' | 'peak-preserving'
select(context: RenderablePointSelectionContext): WaveformPoint[]
}
function selectionBounds(range: VisiblePointRange, pointCount: number) {
return {
start: Math.max(0, range.start - 1),
end: Math.min(pointCount, range.end + 1),
}
}
function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefined) {
if (point && target[target.length - 1] !== point) target.push(point)
}
export class CompletePointSelectionStrategy implements RenderablePointSelectionStrategy {
readonly name = 'complete' as const
select(context: RenderablePointSelectionContext): WaveformPoint[] {
const { start, end } = selectionBounds(context.range, context.points.length)
return context.points.slice(start, end)
}
}
export class PeakPreservingPointSelectionStrategy implements RenderablePointSelectionStrategy {
readonly name = 'peak-preserving' as const
select(context: RenderablePointSelectionContext): WaveformPoint[] {
const { points, range, domain, width, options } = context
const { start, end } = selectionBounds(range, points.length)
const visibleCount = end - start
if (visibleCount <= 0) return []
const domainStart = Math.min(domain[0], domain[1])
const domainEnd = Math.max(domain[0], domain[1])
const maximumPointCount = Math.max(4, Math.floor(width * options.maxPointsPerPixel))
const bucketCount = Math.max(1, Math.floor(maximumPointCount / 4))
const result: WaveformPoint[] = []
const span = domainEnd - domainStart || 1
const bucketIndexes = Array.from({ length: 4 }, () => -1)
let activeBucket = -1
let firstIndex = -1
let lastIndex = -1
let minimumIndex = -1
let maximumIndex = -1
const addBucketIndex = (index: number, count: number) => {
if (index < 0) return count
for (let position = 0; position < count; position += 1) {
if (bucketIndexes[position] === index) return count
}
bucketIndexes[count] = index
return count + 1
}
const flushBucket = () => {
if (firstIndex < 0) return
let count = 0
count = addBucketIndex(firstIndex, count)
count = addBucketIndex(minimumIndex, count)
count = addBucketIndex(maximumIndex, count)
count = addBucketIndex(lastIndex, count)
for (let index = 1; index < count; index += 1) {
const value = bucketIndexes[index]
let position = index - 1
while (position >= 0 && bucketIndexes[position] > value) {
bucketIndexes[position + 1] = bucketIndexes[position]
position -= 1
}
bucketIndexes[position + 1] = value
}
for (let index = 0; index < count; index += 1) {
pushUniquePoint(result, points[bucketIndexes[index]])
}
}
pushUniquePoint(result, points[start])
for (let index = range.start; index < range.end; index += 1) {
const point = points[index]
const bucket = Math.min(
bucketCount - 1,
Math.max(0, Math.floor(((point.x - domainStart) / span) * bucketCount)),
)
if (bucket !== activeBucket) {
flushBucket()
activeBucket = bucket
firstIndex = index
lastIndex = index
minimumIndex = index
maximumIndex = index
continue
}
lastIndex = index
if (point.y < points[minimumIndex].y) minimumIndex = index
if (point.y > points[maximumIndex].y) maximumIndex = index
}
flushBucket()
pushUniquePoint(result, points[end - 1])
return result
}
}
export interface RenderablePointSelectionStrategyRequest {
visibleCount: number
width: number
options: ResolvedWaveformRenderingOptions
}
export class RenderablePointSelectionStrategyFactory {
private readonly complete = new CompletePointSelectionStrategy()
private readonly peakPreserving = new PeakPreservingPointSelectionStrategy()
resolve(request: RenderablePointSelectionStrategyRequest): RenderablePointSelectionStrategy {
const maximumPointCount = Math.max(
4,
Math.floor(request.width * request.options.maxPointsPerPixel),
)
const shouldUseCompletePoints =
!request.options.downsample ||
request.visibleCount <= request.options.downsampleThreshold ||
request.visibleCount <= maximumPointCount
return shouldUseCompletePoints ? this.complete : this.peakPreserving
}
}
const defaultRenderablePointSelectionStrategyFactory = new RenderablePointSelectionStrategyFactory()
export function resolveRenderablePointSelectionStrategy(
request: RenderablePointSelectionStrategyRequest,
): RenderablePointSelectionStrategy {
return defaultRenderablePointSelectionStrategyFactory.resolve(request)
}

View File

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

View File

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

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

View File

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

View File

@@ -0,0 +1,77 @@
import { computed, ref } from 'vue'
import type { WaveformXAxisLabelFormatter } from '../types'
export type DemoXAxisLabelFormat = 'number' | 'datetime'
export type DemoXAxisLabelTimeZone = 'local' | 'Asia/Shanghai' | 'UTC'
const formatOptions = [
{ label: '数值', value: 'number' as const },
{ label: '固定时间', value: 'datetime' as const },
]
const timeZoneOptions = [
{ label: '中国标准时间', value: 'Asia/Shanghai' as const },
{ label: '本地时区', value: 'local' as const },
{ label: 'UTC', value: 'UTC' as const },
]
function pad(value: number, length = 2): string {
return String(value).padStart(length, '0')
}
export function formatDemoTimestamp(
value: number,
timeZone: DemoXAxisLabelTimeZone,
showMilliseconds: boolean,
): string {
const offset = timeZone === 'Asia/Shanghai' ? 8 * 60 * 60 * 1000 : 0
const date = new Date(value + offset)
if (!Number.isFinite(date.getTime())) return String(value)
const useUtcFields = timeZone !== 'local'
const year = useUtcFields ? date.getUTCFullYear() : date.getFullYear()
const month = (useUtcFields ? date.getUTCMonth() : date.getMonth()) + 1
const day = useUtcFields ? date.getUTCDate() : date.getDate()
const hours = useUtcFields ? date.getUTCHours() : date.getHours()
const minutes = useUtcFields ? date.getUTCMinutes() : date.getMinutes()
const seconds = useUtcFields ? date.getUTCSeconds() : date.getSeconds()
const milliseconds = useUtcFields ? date.getUTCMilliseconds() : date.getMilliseconds()
const suffix = showMilliseconds ? `.${pad(milliseconds, 3)}` : ''
return `${year}-${pad(month)}-${pad(day)} ${pad(hours)}:${pad(minutes)}:${pad(seconds)}${suffix}`
}
export function useDemoXAxisLabelControls() {
const xAxisLabelFormatterEnabled = ref(false)
const xAxisLabelFormat = ref<DemoXAxisLabelFormat>('number')
const xAxisLabelMultiplier = ref(1)
const xAxisLabelFractionDigits = ref(3)
const xAxisLabelTimeZone = ref<DemoXAxisLabelTimeZone>('Asia/Shanghai')
const xAxisLabelShowMilliseconds = ref(false)
const xAxisLabelFormatter = computed<WaveformXAxisLabelFormatter | undefined>(() => {
if (!xAxisLabelFormatterEnabled.value) return undefined
if (xAxisLabelFormat.value === 'datetime') {
return (value) =>
formatDemoTimestamp(value, xAxisLabelTimeZone.value, xAxisLabelShowMilliseconds.value)
}
const multiplier = Number.isFinite(xAxisLabelMultiplier.value) ? xAxisLabelMultiplier.value : 1
const fractionDigits = Number.isFinite(xAxisLabelFractionDigits.value)
? Math.min(12, Math.max(0, Math.trunc(xAxisLabelFractionDigits.value)))
: 0
return (value) => (value * multiplier).toFixed(fractionDigits)
})
return {
controlModel: {
xAxisLabelFormatterEnabled,
xAxisLabelFormat,
xAxisLabelFormatOptions: formatOptions,
xAxisLabelMultiplier,
xAxisLabelFractionDigits,
xAxisLabelTimeZone,
xAxisLabelTimeZoneOptions: timeZoneOptions,
xAxisLabelShowMilliseconds,
},
xAxisLabelFormatter,
}
}

View File

@@ -0,0 +1,86 @@
import { flushPromises, mount } from '@vue/test-utils'
import { InputNumber, Select } from 'ant-design-vue'
import { describe, expect, it } from 'vitest'
import App from '../App.vue'
import { WaveformChart } from '../components'
import { formatDemoTimestamp } from './useDemoXAxisLabelControls'
describe('X-axis label demo controls', () => {
it('formats timestamps in fixed UTC and China-standard-time formats', () => {
expect(formatDemoTimestamp(0, 'UTC', false)).toBe('1970-01-01 00:00:00')
expect(formatDemoTimestamp(123, 'UTC', true)).toBe('1970-01-01 00:00:00.123')
expect(formatDemoTimestamp(0, 'Asia/Shanghai', false)).toBe('1970-01-01 08:00:00')
expect(formatDemoTimestamp(Number.NaN, 'UTC', false)).toBe('NaN')
})
it('configures numeric and timestamp labels from the sidebar', async () => {
const wrapper = mount(App)
await flushPromises()
const chart = wrapper.getComponent(WaveformChart)
expect(chart.props('axes')).toEqual({
x: { lineVisible: false },
y: { lineVisible: false },
})
expect(wrapper.find('[aria-label="自定义 X 轴 Label"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="X 轴 Label 小数位数"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="X 轴 Label 格式类型"]').exists()).toBe(false)
await wrapper.get('[aria-label="自定义 X 轴 Label"]').trigger('click')
await flushPromises()
const inputNumbers = wrapper.findAllComponents(InputNumber)
const multiplierInput = inputNumbers.find((input) =>
input.find('[aria-label="X 轴 Label 运算倍率"]').exists(),
)
const fractionDigitsInput = inputNumbers.find((input) =>
input.find('[aria-label="X 轴 Label 小数位数"]').exists(),
)
expect(multiplierInput).toBeDefined()
expect(fractionDigitsInput).toBeDefined()
expect(multiplierInput?.props('value')).toBe(1)
expect(fractionDigitsInput?.props('value')).toBe(3)
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toMatch(/\.000$/)
multiplierInput?.vm.$emit('update:value', 2)
fractionDigitsInput?.vm.$emit('update:value', 2)
await flushPromises()
const formatter = chart.props('axes')?.x?.labelFormatter
expect(formatter).toBeTypeOf('function')
expect(formatter?.(1.234, {} as never)).toBe('2.47')
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toMatch(/\.00$/)
const formatSelect = wrapper
.findAllComponents(Select)
.find((select) => select.find('[aria-label="X 轴 Label 格式类型"]').exists())
expect(formatSelect?.props('value')).toBe('number')
formatSelect?.vm.$emit('update:value', 'datetime')
await flushPromises()
expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="X 轴 Label 小数位数"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="X 轴 Label 时区"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="X 轴 Label 显示毫秒"]').exists()).toBe(true)
expect(chart.props('axes')?.x?.labelFormatter?.(0, {} as never)).toBe('1970-01-01 08:00:00')
await wrapper.get('[aria-label="X 轴 Label 显示毫秒"]').trigger('click')
await flushPromises()
expect(chart.props('axes')?.x?.labelFormatter?.(123, {} as never)).toBe(
'1970-01-01 08:00:00.123',
)
const timeZoneSelect = wrapper
.findAllComponents(Select)
.find((select) => select.find('[aria-label="X 轴 Label 时区"]').exists())
timeZoneSelect?.vm.$emit('update:value', 'UTC')
await flushPromises()
expect(chart.props('axes')?.x?.labelFormatter?.(123, {} as never)).toBe(
'1970-01-01 00:00:00.123',
)
await wrapper.get('[aria-label="自定义 X 轴 Label"]').trigger('click')
await flushPromises()
expect(chart.props('axes')?.x?.labelFormatter).toBeUndefined()
expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
wrapper.unmount()
})
})

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

@@ -17,12 +17,16 @@ export type {
WaveformAnnotationStyle,
WaveformAnnotation,
WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle,
WaveformTitleOptions,
WaveformLegendPosition,
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;
@@ -188,6 +195,7 @@ body {
}
.frame-style-controls,
.plot-margin-controls,
.auxiliary-style-controls {
display: grid;
gap: 10px;

View File

@@ -74,6 +74,14 @@ export interface WaveformRenderingOptions {
errorBarMinSpacing?: number
}
/** Pixel margins reserved above and below the waveform plotting area. */
export interface WaveformPlotMargin {
/** Space between the top of the drawing SVG and the plotting area. Defaults to 18. */
top?: number
/** Space between the plotting area and the bottom of the drawing SVG. Defaults to 52. */
bottom?: number
}
/** Text styling for the chart-level title. */
export interface WaveformTitleTextStyle {
color?: string
@@ -121,10 +129,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

@@ -12,12 +12,16 @@ export type {
WaveformAnnotationStyle,
WaveformAnnotation,
WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle,
WaveformTitleOptions,
WaveformLegendPosition,
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>