11 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
李启源
6cbb1e7668 chore(release): bump version to 0.1.24
All checks were successful
Package component / package (push) Successful in 3m50s
2026-07-29 12:27:30 +08:00
李启源
f97ff24cdf feat(chart): add presentation mode
Some checks failed
Package component / package (push) Failing after 50s
2026-07-29 12:12:03 +08:00
李启源
312e7fde52 chore(release): bump version to 0.1.22
Some checks failed
Package component / package (push) Failing after 3m55s
2026-07-29 11:16:27 +08:00
72 changed files with 2936 additions and 479 deletions

View File

@@ -12,6 +12,9 @@
最新稳定版 Demo<https://lqycustomsite.online/waveform-analysis/> 最新稳定版 Demo<https://lqycustomsite.online/waveform-analysis/>
本地运行 `pnpm dev` 后,可通过
<http://127.0.0.1:5173/#/fixed-y-domain> 查看固定振幅上下限示例。
## 特性 ## 特性
- Vue 3 Composition API + TypeScript支持按需导入 `WaveformChart` - Vue 3 Composition API + TypeScript支持按需导入 `WaveformChart`
@@ -22,6 +25,7 @@
- 缩放过程事件、缩放结束按可视区间加载和视口重置 - 缩放过程事件、缩放结束按可视区间加载和视口重置
- 可选的空格拖拽平移,默认关闭并隔离多图表实例 - 可选的空格拖拽平移,默认关闭并隔离多图表实例
- 多系列图例、受控显隐、网格分页和最多四根 Y 轴 - 多系列图例、受控显隐、网格分页和最多四根 Y 轴
- 自动 Y 轴范围,以及全局、按轨道或按系列配置的固定振幅范围
- 按轨道控制水平/垂直网格线的显隐与颜色 - 按轨道控制水平/垂直网格线的显隐与颜色
- 受控标注、右键编辑、拖拽避让和自定义颜色 - 受控标注、右键编辑、拖拽避让和自定义颜色
- 标题、图框、坐标轴、零值参考线、净图和渲染参数可配置 - 标题、图框、坐标轴、零值参考线、净图和渲染参数可配置
@@ -93,7 +97,7 @@ const data = ref<WaveformData>({
### Props ### Props
| Prop | 类型 | 默认值 | 说明 | | Prop | 类型 | 默认值 | 说明 |
| --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | --------------------------------- | | --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | ---------------------------------- |
| `data` | `WaveformData` | 必填 | 波形数据 | | `data` | `WaveformData` | 必填 | 波形数据 |
| `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 | | `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 |
| `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 | | `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 |
@@ -107,13 +111,16 @@ const data = ref<WaveformData>({
| `minVisiblePoints` | `number` | `0` | 缩放后至少保留的不同 X 坐标数 | | `minVisiblePoints` | `number` | `0` | 缩放后至少保留的不同 X 坐标数 |
| `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围 | | `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围 |
| `initialXDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置初始范围 | | `initialXDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置初始范围 |
| `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 |
| `yDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置固定范围 |
| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 | | `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 |
| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐 | | `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐与 X 轴 label 格式化 |
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 | | `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 | | `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
| `frameNumber` | `string \| number` | 未设置 | 图框水印内容 | | `frameNumber` | `string \| number` | 未设置 | 图框水印内容 |
| `zeroLine` | `WaveformZeroLineOptions` | `{ visible: false }` | 零值参考线显隐与样式 | | `zeroLine` | `WaveformZeroLineOptions` | `{ visible: false }` | 零值参考线显隐与样式 |
| `cleanView` | `boolean` | `false` | 保留波形的净图模式 | | `cleanView` | `boolean` | `false` | 保留波形、图框和刻度的净图模式 |
| `presentationMode` | `boolean` | `false` | 禁用绘图区交互的展示模式 |
| `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 | | `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 |
| `annotationsVisible` | `boolean` | `true` | 标注图层显隐 | | `annotationsVisible` | `boolean` | `true` | 标注图层显隐 |
| `interactionMode` | `'zoom' \| 'annotation'` | `'zoom'` | 左键交互模式 | | `interactionMode` | `'zoom' \| 'annotation'` | `'zoom'` | 左键交互模式 |
@@ -122,8 +129,8 @@ const data = ref<WaveformData>({
所有公开类型均可从包入口导入,例如 `WaveformData``WaveformSeries` 所有公开类型均可从包入口导入,例如 `WaveformData``WaveformSeries`
`WaveformAnnotation``WaveformLineStyle``WaveformRenderingOptions` `WaveformAnnotation``WaveformLineStyle``WaveformRenderingOptions`
`WaveformAxesOptions``WaveformZeroLineOptions``WaveformGridOptions` `WaveformAxesOptions``WaveformXAxisLabelFormatter``WaveformZeroLineOptions`
`WaveformGridTrackLines` `WaveformGridOptions``WaveformGridTrackLines`
### 数据结构 ### 数据结构
@@ -176,11 +183,43 @@ import 'waveform-analysis/style.css'
</template> </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 模式下按住鼠标左键拖拽,松开后同时缩放 组件支持 Plotly 风格的矩形框选缩放:在 zoom 模式下按住鼠标左键拖拽,松开后同时缩放
X/Y 轴;设置 `pannable` 后,指针位于图表内时按住空格键拖拽可平移当前视口。 X/Y 轴;设置 `pannable` 后,指针位于图表内时按住空格键拖拽可平移当前视口。
鼠标滚轮可放大,双击恢复完整视口。 鼠标滚轮可放大和缩小,双击恢复完整视口。
组件会在滚轮或框选缩放结束后触发 `zoom-end`,调用方可以使用端点请求后端,再通过 组件会在滚轮或框选缩放结束后触发 `zoom-end`,调用方可以使用端点请求后端,再通过
`data` 传回新数据。独立分图模式还会包含 `trackIndex` 和稳定的 `seriesIds` `data` 传回新数据。独立分图模式还会包含 `trackIndex` 和稳定的 `seriesIds`
@@ -410,9 +449,10 @@ scale 定位零线:
`dash` 直接对应 SVG 的 `stroke-dasharray`;传入空字符串可显示实线。无效或非正数的 `dash` 直接对应 SVG 的 `stroke-dasharray`;传入空字符串可显示实线。无效或非正数的
`width` 会回退到 `1` `width` 会回退到 `1`
设置 `cleanView` 后,组件隐藏标题内容、图例、网格、坐标轴、轴标签、图框背景与边框、帧水印 设置 `cleanView` 后,组件保留波形、图框边框、X/Y 轴刻度及刻度值,并隐藏标题内容、图例、
零值参考线、标注和分页器,同时保留原图的标题区域、边距和波形尺寸。缩放、悬浮、十字线和 网格、轴标签、图框背景、帧水印、零值参考线、标注和分页器原图的标题区域、边距和波形
tooltip 仍然可用,切换回普通模式后原有配置和标注不会丢失: 尺寸保持不变;缩放、悬浮、十字线和 tooltip 仍然可用,切换回普通模式后原有配置和标注
不会丢失:
```vue ```vue
<WaveformChart :data="chartData" :clean-view="cleanViewEnabled" /> <WaveformChart :data="chartData" :clean-view="cleanViewEnabled" />
@@ -468,9 +508,41 @@ 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`,默认使用缩放模式。右键绘图区可直接打开 `interactionMode` 可选 `zoom``annotation`,默认使用缩放模式。右键绘图区可直接打开
标注编辑器,无需切换交互模式。`zoomable``pannable``showTooltip` 可分别控制缩放、 标注编辑器,无需切换交互模式。`zoomable``pannable``showTooltip` 可分别控制缩放、
空格拖拽平移和 tooltip平移默认关闭。 空格拖拽平移和 tooltip平移默认关闭。
展示场景可启用 `presentationMode`,统一禁用绘图区的 tooltip、缩放、平移、双击复位和
标注交互。该模式不会隐藏任何图形内容,也不会禁用图例切换或分页;关闭后恢复原交互配置。
```vue
<WaveformChart :data="chartData" :presentation-mode="true" />
```
空数据或过滤后没有有效点时,组件会保留图框布局并显示“暂无有效波形数据”。 空数据或过滤后没有有效点时,组件会保留图框布局并显示“暂无有效波形数据”。
## 大数据渲染 ## 大数据渲染
@@ -555,7 +627,7 @@ async function importAnnotationFile(file: File) {
字段无效时会抛出 `TypeError`,不会返回部分结果。导入包含未知 `seriesId` 的标注是允许的, 字段无效时会抛出 `TypeError`,不会返回部分结果。导入包含未知 `seriesId` 的标注是允许的,
对应曲线加载后会恢复显示。文件选择、错误提示和下载由业务层实现。 对应曲线加载后会恢复显示。文件选择、错误提示和下载由业务层实现。
XY 轴会根据各自完整显示域选择格式:最大绝对值在 `[0.01, 100)` 时显示两位普通小数;大于等于 `100`,或大于 `0` 且小于 `0.01` 时,刻度显示两位缩放值,并在轴末端单独显示共享倍率 `E±NN`。X 轴先按 `timeUnit` 转换为秒或毫秒再判断范围,多 Y 轴分别计算倍率。tooltip 使用最多 4 位小数的本地化普通数字;标注编辑器的 X 坐标跟随 `timeUnit` 并固定 3 位小数Y 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。 X 轴刻度和左右端点先按 `timeUnit` 转换为秒或毫秒,再显示为不带分组符和科学计数法的完整普通十进制值,并可通过 `axes.x.labelFormatter` 自定义。Y 轴会根据完整显示域选择格式:最大绝对值在 `[0.01, 100)` 时显示两位普通小数;大于等于 `100`,或大于 `0` 且小于 `0.01` 时,刻度显示两位缩放值,并在轴末端单独显示共享倍率 `E±NN`,多 Y 轴分别计算倍率。tooltip 使用最多 4 位小数的本地化普通数字并省略无意义尾零;标注编辑器的 X 坐标跟随 `timeUnit` 并固定 3 位小数Y 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。
标注框默认布局在采样点正上方,只做绘图区边界裁剪;文本框通过连接箭头指向标注位置,多个标注重叠时可通过拖动手动避让。 标注框默认布局在采样点正上方,只做绘图区边界裁剪;文本框通过连接箭头指向标注位置,多个标注重叠时可通过拖动手动避让。
## 事件 ## 事件
@@ -580,7 +652,8 @@ X、Y 轴会根据各自完整显示域选择格式:最大绝对值在 `[0.01,
- `src/index.ts`:组件库公开入口和工具函数导出 - `src/index.ts`:组件库公开入口和工具函数导出
- `src/components/WaveformChart.vue`图表容器、缩放、tooltip、图例和标注编排 - `src/components/WaveformChart.vue`图表容器、缩放、tooltip、图例和标注编排
- `src/components/{core,data,rendering,interaction,annotation}`:数据、布局、渲染和交互模块 - `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", "name": "waveform-analysis",
"version": "0.1.21", "version": "0.1.30",
"main": "./dist/index.cjs", "main": "./dist/index.cjs",
"module": "./dist/index.js", "module": "./dist/index.js",
"types": "./dist/types/index.d.ts", "types": "./dist/types/index.d.ts",
@@ -14,6 +14,7 @@
}, },
"files": [ "files": [
"dist", "dist",
"src",
"README.md" "README.md"
], ],
"sideEffects": [ "sideEffects": [
@@ -37,6 +38,7 @@
"dependencies": { "dependencies": {
"ant-design-vue": "3.2.20", "ant-design-vue": "3.2.20",
"d3": "^7.9.0", "d3": "^7.9.0",
"vue-router": "^4.6.4",
"vue3-colorpicker": "^2.3.0" "vue3-colorpicker": "^2.3.0"
}, },
"peerDependencies": { "peerDependencies": {

18
pnpm-lock.yaml generated
View File

@@ -14,6 +14,9 @@ importers:
d3: d3:
specifier: ^7.9.0 specifier: ^7.9.0
version: 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: vue3-colorpicker:
specifier: ^2.3.0 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)) 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': '@vue/compiler-ssr@3.5.40':
resolution: {integrity: sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==} resolution: {integrity: sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==}
'@vue/devtools-api@6.6.4':
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
'@vue/language-core@3.3.7': '@vue/language-core@3.3.7':
resolution: {integrity: sha512-LzmkKinXAMMoh8Jfi/jMUSDUjuPdv8mynH5WJGKfXyZtDw3hQ6GBaoI6Bcnl/Xqlu32q/0Z6i/trp4VXykzyLw==} resolution: {integrity: sha512-LzmkKinXAMMoh8Jfi/jMUSDUjuPdv8mynH5WJGKfXyZtDw3hQ6GBaoI6Bcnl/Xqlu32q/0Z6i/trp4VXykzyLw==}
@@ -1704,6 +1710,11 @@ packages:
peerDependencies: peerDependencies:
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 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: vue-tsc@3.3.7:
resolution: {integrity: sha512-+C+rgD49wAQ5bUTl2sp5a8Bzg4YoldMNXM+g7CFe604MYcQ8PrZPMQhIjJSzKXtPBCa+C5ayMipqjbA7splekQ==} resolution: {integrity: sha512-+C+rgD49wAQ5bUTl2sp5a8Bzg4YoldMNXM+g7CFe604MYcQ8PrZPMQhIjJSzKXtPBCa+C5ayMipqjbA7splekQ==}
hasBin: true hasBin: true
@@ -2378,6 +2389,8 @@ snapshots:
'@vue/compiler-dom': 3.5.40 '@vue/compiler-dom': 3.5.40
'@vue/shared': 3.5.40 '@vue/shared': 3.5.40
'@vue/devtools-api@6.6.4': {}
'@vue/language-core@3.3.7': '@vue/language-core@3.3.7':
dependencies: dependencies:
'@volar/language-core': 2.4.28 '@volar/language-core': 2.4.28
@@ -3386,6 +3399,11 @@ snapshots:
transitivePeerDependencies: transitivePeerDependencies:
- supports-color - 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): vue-tsc@3.3.7(typescript@6.0.3):
dependencies: dependencies:
'@volar/typescript': 2.4.28 '@volar/typescript': 2.4.28

View File

@@ -92,19 +92,22 @@ describe('App workspace layout', { timeout: 20_000 }, () => {
wrapper.unmount() wrapper.unmount()
}) })
it('passes clean view and zero-line controls to the chart', async () => { it('passes clean view, presentation mode, and zero-line controls to the chart', async () => {
const wrapper = mount(App) const wrapper = mount(App)
await flushPromises() await flushPromises()
const chart = wrapper.getComponent(WaveformChart) const chart = wrapper.getComponent(WaveformChart)
expect(chart.props('cleanView')).toBe(false) expect(chart.props('cleanView')).toBe(false)
expect(chart.props('presentationMode')).toBe(false)
expect(chart.props('zeroLine')).toMatchObject({ visible: false, color: '#98a2b3', width: 1 }) expect(chart.props('zeroLine')).toMatchObject({ visible: false, color: '#98a2b3', width: 1 })
await wrapper.get('[aria-label="净图模式"]').trigger('click') await wrapper.get('[aria-label="净图模式"]').trigger('click')
await wrapper.get('[aria-label="展示模式"]').trigger('click')
await wrapper.get('[aria-label="显示零值参考线"]').trigger('click') await wrapper.get('[aria-label="显示零值参考线"]').trigger('click')
await flushPromises() await flushPromises()
expect(chart.props('cleanView')).toBe(true) expect(chart.props('cleanView')).toBe(true)
expect(chart.props('presentationMode')).toBe(true)
expect(chart.props('zeroLine')).toMatchObject({ visible: true, color: '#98a2b3', width: 1 }) expect(chart.props('zeroLine')).toMatchObject({ visible: true, color: '#98a2b3', width: 1 })
wrapper.unmount() wrapper.unmount()
}) })

View File

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

@@ -104,6 +104,11 @@
cursor: crosshair; cursor: crosshair;
} }
.waveform-chart--presentation .waveform-chart__overlay {
cursor: default;
pointer-events: none;
}
.waveform-chart__label, .waveform-chart__label,
.waveform-chart__empty { .waveform-chart__empty {
fill: #595959; fill: #595959;

View File

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

View File

@@ -13,11 +13,13 @@ const {
displayMode, displayMode,
activeInteractionMode, activeInteractionMode,
isCleanView, isCleanView,
isPresentationMode,
selection, selection,
containerStyle, containerStyle,
overlayMode, overlayMode,
resolvedChartLeftMargin, resolvedChartLeftMargin,
titleAreaHeight, titleAreaHeight,
resolvedPlotMargin,
pointerInsideChart, pointerInsideChart,
handleNativeContextMenu, handleNativeContextMenu,
titleAreaReserved, titleAreaReserved,
@@ -45,7 +47,7 @@ const {
beginSharedViewportDrag, beginSharedViewportDrag,
finishViewportDrag, finishViewportDrag,
cancelViewportDrag, cancelViewportDrag,
clearHover, handlePointerLeave,
handleAnnotationClick, handleAnnotationClick,
handleAnnotationContextMenu, handleAnnotationContextMenu,
resolveFrameNumber, resolveFrameNumber,
@@ -107,14 +109,18 @@ const {
`waveform-chart--interaction-${activeInteractionMode}`, `waveform-chart--interaction-${activeInteractionMode}`,
{ {
'waveform-chart--clean': isCleanView, 'waveform-chart--clean': isCleanView,
'waveform-chart--panning': selection?.mode === 'pan', 'waveform-chart--presentation': isPresentationMode,
'waveform-chart--panning': selection?.kind === 'pan',
}, },
]" ]"
:style="containerStyle" :style="containerStyle"
:data-display-mode="displayMode" :data-display-mode="displayMode"
:data-interaction-mode="activeInteractionMode" :data-interaction-mode="activeInteractionMode"
:data-presentation-mode="isPresentationMode"
:data-overlay-mode="overlayMode" :data-overlay-mode="overlayMode"
:data-chart-left-margin="resolvedChartLeftMargin" :data-chart-left-margin="resolvedChartLeftMargin"
:data-plot-margin-top="resolvedPlotMargin.top"
:data-plot-margin-bottom="resolvedPlotMargin.bottom"
:data-title-area-height="titleAreaHeight" :data-title-area-height="titleAreaHeight"
@pointerenter="pointerInsideChart = true" @pointerenter="pointerInsideChart = true"
@pointerleave="pointerInsideChart = false" @pointerleave="pointerInsideChart = false"
@@ -192,8 +198,8 @@ const {
:ref="setSharedOverlayElement" :ref="setSharedOverlayElement"
class="waveform-chart__overlay waveform-chart__overlay--shared" class="waveform-chart__overlay waveform-chart__overlay--shared"
:class="{ :class="{
'is-zoomable': zoomable && isZoomMode, 'is-zoomable': !isPresentationMode && zoomable && isZoomMode,
'is-annotating': activeInteractionMode === 'annotation', 'is-annotating': !isPresentationMode && activeInteractionMode === 'annotation',
}" }"
:width="innerWidth" :width="innerWidth"
:height="innerHeight" :height="innerHeight"
@@ -201,7 +207,7 @@ const {
@pointerdown="beginSharedViewportDrag" @pointerdown="beginSharedViewportDrag"
@pointerup="finishViewportDrag" @pointerup="finishViewportDrag"
@pointercancel="cancelViewportDrag" @pointercancel="cancelViewportDrag"
@pointerleave="clearHover" @pointerleave="handlePointerLeave"
@click="handleAnnotationClick" @click="handleAnnotationClick"
@contextmenu="handleAnnotationContextMenu" @contextmenu="handleAnnotationContextMenu"
/> />
@@ -214,6 +220,7 @@ const {
:clip-path-id="clipPathId" :clip-path-id="clipPathId"
:inner-width="innerWidth" :inner-width="innerWidth"
:zoomable="zoomable" :zoomable="zoomable"
:interactive="!isPresentationMode"
:display-mode="displayMode" :display-mode="displayMode"
:interaction-mode="activeInteractionMode" :interaction-mode="activeInteractionMode"
:frame-number="resolveFrameNumber(track.index)" :frame-number="resolveFrameNumber(track.index)"
@@ -227,7 +234,7 @@ const {
@pointer-down="beginViewportDrag($event, track.index, true)" @pointer-down="beginViewportDrag($event, track.index, true)"
@pointer-up="finishViewportDrag" @pointer-up="finishViewportDrag"
@pointer-cancel="cancelViewportDrag" @pointer-cancel="cancelViewportDrag"
@pointer-leave="clearHover" @pointer-leave="handlePointerLeave"
@click="handleAnnotationClick($event, track.index)" @click="handleAnnotationClick($event, track.index)"
@contextmenu="handleAnnotationContextMenu($event, track.index)" @contextmenu="handleAnnotationContextMenu($event, track.index)"
/> />
@@ -236,11 +243,11 @@ const {
:state="hoverState" :state="hoverState"
:tracks="trackLayouts" :tracks="trackLayouts"
:clip-path-id="clipPathId" :clip-path-id="clipPathId"
:visible="showTooltip" :visible="showTooltip && !isPresentationMode"
/> />
<rect <rect
v-if="selectionBox && selection?.mode === 'box'" v-if="selectionBox && selection?.kind === 'box'"
class="waveform-chart__zoom-selection" class="waveform-chart__zoom-selection"
:x="selectionBox.x" :x="selectionBox.x"
:y="selectionBox.y" :y="selectionBox.y"
@@ -253,6 +260,7 @@ const {
v-if="!isCleanView" v-if="!isCleanView"
:annotations="renderedAnnotations" :annotations="renderedAnnotations"
:visible="annotationsVisible" :visible="annotationsVisible"
:interactive="!isPresentationMode"
@contextmenu="handleExistingAnnotationContextMenu" @contextmenu="handleExistingAnnotationContextMenu"
@drag-start="beginAnnotationDrag" @drag-start="beginAnnotationDrag"
@move="handleAnnotationMove" @move="handleAnnotationMove"
@@ -282,17 +290,17 @@ const {
/> />
</g> </g>
</g> </g>
</g>
<text <text
v-if="resolvedXLabel && !isCleanView" v-if="resolvedXLabel && !isCleanView"
class="waveform-chart__label waveform-chart__x-label" class="waveform-chart__label waveform-chart__x-label"
:x="innerWidth / 2" :x="resolvedChartLeftMargin + innerWidth / 2"
:y="xAxisTitleY" :y="xAxisTitleY"
text-anchor="middle" text-anchor="middle"
> >
{{ resolvedXLabel }} {{ resolvedXLabel }}
</text> </text>
</g>
<text <text
v-if="hasChartArea && !hasWaveformData" v-if="hasChartArea && !hasWaveformData"
@@ -318,7 +326,7 @@ const {
/> />
<WaveformAnnotationEditor <WaveformAnnotationEditor
v-if="editorDraft && !isCleanView" v-if="editorDraft && !isCleanView && !isPresentationMode"
:annotation="editorDraft.annotation" :annotation="editorDraft.annotation"
:mode="editorDraft.mode" :mode="editorDraft.mode"
:series="editorSeries" :series="editorSeries"
@@ -330,7 +338,7 @@ const {
/> />
<WaveformAnnotationContextMenu <WaveformAnnotationContextMenu
v-if="!isCleanView" v-if="!isCleanView && !isPresentationMode"
:visible="contextMenu !== null" :visible="contextMenu !== null"
:x="contextMenu?.x || 0" :x="contextMenu?.x || 0"
:y="contextMenu?.y || 0" :y="contextMenu?.y || 0"
@@ -342,7 +350,7 @@ const {
<WaveformHoverHost <WaveformHoverHost
:state="hoverState" :state="hoverState"
:visible="showTooltip" :visible="showTooltip && !isPresentationMode"
:time-unit="timeUnit" :time-unit="timeUnit"
:container-width="chartWidth" :container-width="chartWidth"
:container-height="chartHeight" :container-height="chartHeight"

View File

@@ -7,9 +7,12 @@ import { ANNOTATION_TEXT_FONT, ANNOTATION_TEXT_LINE_HEIGHT } from './markup'
interface Props { interface Props {
annotations: RenderedAnnotation[] annotations: RenderedAnnotation[]
visible: boolean visible: boolean
interactive?: boolean
} }
const props = defineProps<Props>() const props = withDefaults(defineProps<Props>(), {
interactive: true,
})
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'contextmenu', annotationId: string, mouseEvent: MouseEvent): void (event: 'contextmenu', annotationId: string, mouseEvent: MouseEvent): void
(event: 'drag-start'): void (event: 'drag-start'): void
@@ -79,7 +82,7 @@ function scheduleDragFrame() {
} }
function handlePointerDown(rendered: RenderedAnnotation, event: PointerEvent) { function handlePointerDown(rendered: RenderedAnnotation, event: PointerEvent) {
if (event.button !== 0 || dragState) return if (!props.interactive || event.button !== 0 || dragState) return
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
const target = event.currentTarget as SVGGElement | null const target = event.currentTarget as SVGGElement | null
@@ -108,6 +111,7 @@ function handlePointerDown(rendered: RenderedAnnotation, event: PointerEvent) {
} }
function handlePointerMove(event: PointerEvent) { function handlePointerMove(event: PointerEvent) {
if (!props.interactive) return
event.stopPropagation() event.stopPropagation()
if (!dragState || event.pointerId !== dragState.pointerId) return if (!dragState || event.pointerId !== dragState.pointerId) return
const deltaX = event.clientX - dragState.startX const deltaX = event.clientX - dragState.startX
@@ -119,6 +123,7 @@ function handlePointerMove(event: PointerEvent) {
} }
function finishPointerDrag(event: PointerEvent) { function finishPointerDrag(event: PointerEvent) {
if (!props.interactive) return
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (!dragState || event.pointerId !== dragState.pointerId) return if (!dragState || event.pointerId !== dragState.pointerId) return
@@ -181,6 +186,11 @@ function handlePointerCancel(event: PointerEvent) {
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
if (!dragState || event.pointerId !== dragState.pointerId) return if (!dragState || event.pointerId !== dragState.pointerId) return
cancelActiveDrag()
}
function cancelActiveDrag() {
if (!dragState) return
const state = dragState const state = dragState
dragState = null dragState = null
dragOffsets.value = new Map(dragOffsets.value).set(state.annotationId, { x: 0, y: 0 }) dragOffsets.value = new Map(dragOffsets.value).set(state.annotationId, { x: 0, y: 0 })
@@ -188,10 +198,21 @@ function handlePointerCancel(event: PointerEvent) {
cancelAnimationFrame(dragFrame) cancelAnimationFrame(dragFrame)
dragFrame = null dragFrame = null
} }
if (state.target.hasPointerCapture(state.pointerId)) {
state.target.releasePointerCapture(state.pointerId)
}
emit('drag-end', true) emit('drag-end', true)
} }
watch(
() => props.interactive,
(interactive) => {
if (!interactive) cancelActiveDrag()
},
)
function handleContextMenu(annotationId: string, event: MouseEvent) { function handleContextMenu(annotationId: string, event: MouseEvent) {
if (!props.interactive) return
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
// 使用时间戳比较:如果 contextmenu 在拖动结束后 100ms 内触发,则抑制 // 使用时间戳比较:如果 contextmenu 在拖动结束后 100ms 内触发,则抑制
@@ -206,7 +227,11 @@ function markerId(annotationId: string) {
</script> </script>
<template> <template>
<g v-if="props.visible" class="waveform-annotation-layer"> <g
v-if="props.visible"
class="waveform-annotation-layer"
:class="{ 'waveform-annotation-layer--interactive': props.interactive }"
>
<g <g
v-for="rendered in props.annotations" v-for="rendered in props.annotations"
:key="rendered.annotation.id" :key="rendered.annotation.id"
@@ -281,13 +306,13 @@ function markerId(annotationId: string) {
pointer-events: none; pointer-events: none;
} }
.waveform-annotation { .waveform-annotation-layer--interactive .waveform-annotation {
pointer-events: auto; pointer-events: auto;
cursor: grab; cursor: grab;
touch-action: none; touch-action: none;
} }
.waveform-annotation:active { .waveform-annotation-layer--interactive .waveform-annotation:active {
cursor: grabbing; cursor: grabbing;
} }
@@ -300,6 +325,9 @@ function markerId(annotationId: string) {
.waveform-annotation__box { .waveform-annotation__box {
stroke-width: 1; stroke-width: 1;
rx: 3; rx: 3;
}
.waveform-annotation-layer--interactive .waveform-annotation__box {
pointer-events: auto; pointer-events: auto;
} }

View File

@@ -38,6 +38,7 @@ interface AnnotationContext {
titleAreaHeight: ComputedRef<number> titleAreaHeight: ComputedRef<number>
chartTopMargin: ComputedRef<number> chartTopMargin: ComputedRef<number>
activeInteractionMode: ComputedRef<WaveformInteractionMode | undefined> activeInteractionMode: ComputedRef<WaveformInteractionMode | undefined>
isPresentationMode: ComputedRef<boolean>
} }
interface AnnotationCandidateContext { interface AnnotationCandidateContext {
@@ -75,6 +76,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
titleAreaHeight, titleAreaHeight,
chartTopMargin, chartTopMargin,
activeInteractionMode, activeInteractionMode,
isPresentationMode,
} = context } = context
function toggleSeriesVisibility(seriesId: string) { function toggleSeriesVisibility(seriesId: string) {
@@ -140,6 +142,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
} }
function changeDraftSeries(seriesId: string) { function changeDraftSeries(seriesId: string) {
if (isPresentationMode.value) return
const draft = annotationInteraction.editorDraft.value const draft = annotationInteraction.editorDraft.value
const candidate = editorSeriesOptions.value.find((item) => item.seriesId === seriesId) const candidate = editorSeriesOptions.value.find((item) => item.seriesId === seriesId)
const track = trackLayouts.value.find((item) => const track = trackLayouts.value.find((item) =>
@@ -216,7 +219,13 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
} }
function handleAnnotationClick(event: MouseEvent, trackIndex?: number) { function handleAnnotationClick(event: MouseEvent, trackIndex?: number) {
if (!props.annotationsVisible || activeInteractionMode.value !== 'annotation') return if (
isPresentationMode.value ||
!props.annotationsVisible ||
activeInteractionMode.value !== 'annotation'
) {
return
}
const candidateContext = resolveAnnotationCandidates(event, trackIndex) const candidateContext = resolveAnnotationCandidates(event, trackIndex)
if (!candidateContext) return if (!candidateContext) return
const nearby = nearbyCandidates(candidateContext.candidates) const nearby = nearbyCandidates(candidateContext.candidates)
@@ -238,7 +247,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
} }
function handleAnnotationContextMenu(event: MouseEvent, trackIndex?: number) { function handleAnnotationContextMenu(event: MouseEvent, trackIndex?: number) {
if (!props.annotationsVisible) return if (isPresentationMode.value || !props.annotationsVisible) return
event.preventDefault() event.preventDefault()
event.stopPropagation() event.stopPropagation()
const candidateContext = resolveAnnotationCandidates(event, trackIndex) const candidateContext = resolveAnnotationCandidates(event, trackIndex)
@@ -252,7 +261,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
} }
function handleExistingAnnotationContextMenu(annotationId: string, event: MouseEvent) { function handleExistingAnnotationContextMenu(annotationId: string, event: MouseEvent) {
if (!props.annotationsVisible || !container.value) return if (isPresentationMode.value || !props.annotationsVisible || !container.value) return
editorSeriesOptions.value = [] editorSeriesOptions.value = []
const annotation = props.annotations.find((item) => item.id === annotationId) const annotation = props.annotations.find((item) => item.id === annotationId)
if (!annotation) return if (!annotation) return
@@ -270,6 +279,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
} }
function handleAnnotationMove(annotationId: string, offsetX: number, offsetY: number) { function handleAnnotationMove(annotationId: string, offsetX: number, offsetY: number) {
if (isPresentationMode.value) return
const annotation = props.annotations.find((item) => item.id === annotationId) const annotation = props.annotations.find((item) => item.id === annotationId)
if (!annotation || !Number.isFinite(offsetX) || !Number.isFinite(offsetY)) return if (!annotation || !Number.isFinite(offsetX) || !Number.isFinite(offsetY)) return
emit( emit(
@@ -281,6 +291,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
} }
function editContextAnnotation() { function editContextAnnotation() {
if (isPresentationMode.value) return
const menu = annotationInteraction.contextMenu.value const menu = annotationInteraction.contextMenu.value
const annotation = props.annotations.find((item) => item.id === menu?.annotationId) const annotation = props.annotations.find((item) => item.id === menu?.annotationId)
if (!annotation) return if (!annotation) return
@@ -302,6 +313,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
} }
function deleteContextAnnotation() { function deleteContextAnnotation() {
if (isPresentationMode.value) return
const annotationId = annotationInteraction.contextMenu.value?.annotationId const annotationId = annotationInteraction.contextMenu.value?.annotationId
const annotation = props.annotations.find((item) => item.id === annotationId) const annotation = props.annotations.find((item) => item.id === annotationId)
if (!annotation) return if (!annotation) return
@@ -314,6 +326,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
} }
function confirmAnnotation(annotation: WaveformAnnotation) { function confirmAnnotation(annotation: WaveformAnnotation) {
if (isPresentationMode.value) return
const draft = annotationInteraction.editorDraft.value const draft = annotationInteraction.editorDraft.value
if (!draft) return if (!draft) return
if (draft.mode === 'add') { if (draft.mode === 'add') {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -6,6 +6,7 @@ import {
shallowReactive, shallowReactive,
shallowRef, shallowRef,
toRefs, toRefs,
watch,
watchEffect, watchEffect,
type ComponentPublicInstance, type ComponentPublicInstance,
type Ref, type Ref,
@@ -17,6 +18,7 @@ import { useWaveformChartAnnotations } from '../annotation/useWaveformChartAnnot
import { useWaveformHover } from '../interaction/useWaveformHover' import { useWaveformHover } from '../interaction/useWaveformHover'
import { useWaveformViewport } from '../interaction/useWaveformViewport' import { useWaveformViewport } from '../interaction/useWaveformViewport'
import { useWaveformZoom } from '../interaction/useWaveformZoom' import { useWaveformZoom } from '../interaction/useWaveformZoom'
import { ViewportInteractionStateMachine } from '../interaction/viewportInteractionState'
import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle' import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
import { margin } from './constants' import { margin } from './constants'
import { getPageSize } from './grid' import { getPageSize } from './grid'
@@ -68,7 +70,17 @@ export function useWaveformChartController(
const annotationInteraction = useWaveformAnnotationInteraction() const annotationInteraction = useWaveformAnnotationInteraction()
const editorSeriesOptions = ref<AnnotationSeriesCandidate[]>([]) const editorSeriesOptions = ref<AnnotationSeriesCandidate[]>([])
const hoverThrottle = useAnimationFrameThrottle() 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 spacePressed = ref(false)
const pointerInsideChart = ref(false) const pointerInsideChart = ref(false)
let handleDataReferenceChange: () => void = () => undefined let handleDataReferenceChange: () => void = () => undefined
@@ -90,6 +102,7 @@ export function useWaveformChartController(
chartWidth, chartWidth,
chartHeight, chartHeight,
isCleanView, isCleanView,
isPresentationMode,
hiddenSeriesIdSet, hiddenSeriesIdSet,
resolvedTitleText, resolvedTitleText,
titleAreaReserved, titleAreaReserved,
@@ -151,6 +164,7 @@ export function useWaveformChartController(
innerHeight, innerHeight,
hasChartArea, hasChartArea,
isZoomMode, isZoomMode,
isPresentationMode,
resolveInitialTrackDomain, resolveInitialTrackDomain,
cancelPendingHover: () => hover.cancelPendingHover(), cancelPendingHover: () => hover.cancelPendingHover(),
}) })
@@ -174,12 +188,14 @@ export function useWaveformChartController(
titleAreaHeight, titleAreaHeight,
chartTopMargin, chartTopMargin,
activeInteractionMode, activeInteractionMode,
isPresentationMode,
}) })
const viewport = useWaveformViewport({ const viewport = useWaveformViewport({
props, props,
emit, emit,
selection, selection,
viewportInteraction,
spacePressed, spacePressed,
trackLayouts, trackLayouts,
chartTracks, chartTracks,
@@ -192,6 +208,7 @@ export function useWaveformChartController(
sharedYDomains, sharedYDomains,
independentYDomains, independentYDomains,
isZoomMode, isZoomMode,
isPresentationMode,
editorSeriesOptions, editorSeriesOptions,
resolveInitialTrackDomain, resolveInitialTrackDomain,
canZoomTrack: zoom.canZoomTrack, canZoomTrack: zoom.canZoomTrack,
@@ -214,6 +231,7 @@ export function useWaveformChartController(
titleAreaHeight, titleAreaHeight,
chartTopMargin, chartTopMargin,
sharedOverlayElement, sharedOverlayElement,
isPresentationMode,
updateViewportDrag: viewport.updateViewportDrag, updateViewportDrag: viewport.updateViewportDrag,
resolveTrackAtPointer: annotations.resolveTrackAtPointer, resolveTrackAtPointer: annotations.resolveTrackAtPointer,
}) })
@@ -248,10 +266,12 @@ export function useWaveformChartController(
independentYDomains, independentYDomains,
annotationInteraction, annotationInteraction,
editorSeriesOptions, editorSeriesOptions,
isPresentationMode,
clearHover: hover.clearHover, clearHover: hover.clearHover,
cancelAnnotation: annotations.cancelAnnotation, cancelAnnotation: annotations.cancelAnnotation,
configureZoom: zoom.configureZoom, configureZoom: zoom.configureZoom,
resetViewport: viewport.resetViewport, resetViewport: viewport.resetViewport,
cancelViewportDrag: viewport.cancelViewportDrag,
cancelPendingHover: hover.cancelPendingHover, cancelPendingHover: hover.cancelPendingHover,
clearZoomBindings: zoom.clearZoomBindings, clearZoomBindings: zoom.clearZoomBindings,
}) })

View File

@@ -44,10 +44,12 @@ interface LifecycleContext {
independentYDomains: Ref<Record<number, [number, number]>> independentYDomains: Ref<Record<number, [number, number]>>
annotationInteraction: ReturnType<typeof useWaveformAnnotationInteraction> annotationInteraction: ReturnType<typeof useWaveformAnnotationInteraction>
editorSeriesOptions: Ref<AnnotationSeriesCandidate[]> editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
isPresentationMode: ComputedRef<boolean>
clearHover: () => void clearHover: () => void
cancelAnnotation: () => void cancelAnnotation: () => void
configureZoom: () => void configureZoom: () => void
resetViewport: () => void resetViewport: () => void
cancelViewportDrag: () => void
cancelPendingHover: () => void cancelPendingHover: () => void
clearZoomBindings: () => void clearZoomBindings: () => void
} }
@@ -94,16 +96,25 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
independentYDomains, independentYDomains,
annotationInteraction, annotationInteraction,
editorSeriesOptions, editorSeriesOptions,
isPresentationMode,
clearHover, clearHover,
cancelAnnotation, cancelAnnotation,
configureZoom, configureZoom,
resetViewport, resetViewport,
cancelViewportDrag,
cancelPendingHover, cancelPendingHover,
clearZoomBindings, clearZoomBindings,
} = context } = context
function handleInteractionKeyDown(event: KeyboardEvent) { function handleInteractionKeyDown(event: KeyboardEvent) {
if (event.code !== 'Space' || !props.pannable || !pointerInsideChart.value) return if (
isPresentationMode.value ||
event.code !== 'Space' ||
!props.pannable ||
!pointerInsideChart.value
) {
return
}
if (isEditableTarget(event.target)) return if (isEditableTarget(event.target)) return
spacePressed.value = true spacePressed.value = true
event.preventDefault() event.preventDefault()
@@ -145,6 +156,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
innerWidth, innerWidth,
innerHeight, innerHeight,
() => props.zoomable, () => props.zoomable,
isPresentationMode,
() => props.minZoomSpan, () => props.minZoomSpan,
() => props.initialXDomain, () => props.initialXDomain,
() => props.initialXDomains, () => props.initialXDomains,
@@ -191,6 +203,16 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
editorSeriesOptions.value = [] editorSeriesOptions.value = []
}) })
watch(isPresentationMode, (enabled) => {
spacePressed.value = false
if (!enabled) return
cancelViewportDrag()
clearZoomBindings()
clearHover()
annotationInteraction.closeContextMenu()
cancelAnnotation()
})
watch( watch(
() => chartSeries.value.map((series) => series.id).join('\u0000'), () => chartSeries.value.map((series) => series.id).join('\u0000'),
() => { () => {

View File

@@ -2,7 +2,7 @@ import { scaleLinear, type ZoomTransform } from 'd3'
import { computed, type ComputedRef, type Ref, type ShallowRef } from 'vue' import { computed, type ComputedRef, type Ref, type ShallowRef } from 'vue'
import { resolveWaveformRenderingOptions } from '../../core' import { resolveWaveformRenderingOptions } from '../../core'
import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../../utils' import { paddedDomain } from '../../utils'
import { import {
layoutAnnotations, layoutAnnotations,
type AnnotationSeriesInfo, type AnnotationSeriesInfo,
@@ -24,9 +24,13 @@ import {
normalizeGridOptions, normalizeGridOptions,
paginateSeries, paginateSeries,
resolveGridCellGeometry, resolveGridCellGeometry,
X_AXIS_BAND,
} from './grid' } 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 { DisplaySeries, DisplayTrack, TrackLayout } from './types'
import type { PreparedWaveformSeries } from './useWaveformData' import type { PreparedWaveformSeries } from './useWaveformData'
import type { ResolvedWaveformChartProps } from './waveformChartTypes' import type { ResolvedWaveformChartProps } from './waveformChartTypes'
@@ -104,39 +108,21 @@ export function useWaveformLayout(context: LayoutContext) {
const yAxisMetrics = computed(() => { const yAxisMetrics = computed(() => {
const axisText = chartTracks.value const axisText = chartTracks.value
.filter((track) => track.visibleSeries.length > 0) .filter((track) => track.visibleSeries.length > 0)
.map((track) => { .flatMap((track) =>
const scale = scaleLinear(track.yDomain, [1, 0]).nice() resolveYAxisSeriesGroups(track, props.overlayMode, props.yDomain, props.yDomains),
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 .map((group) => axisTextMetrics(group.domain, !group.fixed).tickTextWidth)
const maximumExponentWidth = Math.max( const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText)
0, const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING
...axisText.map(({ exponentLabel }) => (exponentLabel?.length ?? 0) * Y_AXIS_CHARACTER_WIDTH),
)
const exponentClearance = maximumExponentWidth ? maximumExponentWidth + Y_AXIS_EXPONENT_GAP : 0
const tickClearance =
tickTextWidth + Y_AXIS_TICK_PADDING + exponentClearance + Y_AXIS_OUTER_PADDING
const labelCenterX = -( const labelCenterX = -(
Y_AXIS_TICK_PADDING + Y_AXIS_TICK_PADDING +
tickTextWidth + tickTextWidth +
exponentClearance +
Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH / 2 Y_AXIS_LABEL_BAND_WIDTH / 2
) )
const fullClearance = const fullClearance =
tickTextWidth + tickTextWidth +
Y_AXIS_TICK_PADDING + Y_AXIS_TICK_PADDING +
exponentClearance +
Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_GAP +
Y_AXIS_LABEL_BAND_WIDTH + Y_AXIS_LABEL_BAND_WIDTH +
Y_AXIS_OUTER_PADDING Y_AXIS_OUTER_PADDING
@@ -165,7 +151,12 @@ export function useWaveformLayout(context: LayoutContext) {
const multiAxisClearance = computed(() => const multiAxisClearance = computed(() =>
chartTracks.value.reduce( chartTracks.value.reduce(
(maximum, track) => { (maximum, track) => {
const clearance = measureTrackYAxisClearance(track, props.overlayMode) const clearance = measureTrackYAxisClearance(
track,
props.overlayMode,
props.yDomain,
props.yDomains,
)
return { return {
left: Math.max(maximum.left, clearance.left), left: Math.max(maximum.left, clearance.left),
right: Math.max(maximum.right, clearance.right), right: Math.max(maximum.right, clearance.right),
@@ -281,6 +272,8 @@ export function useWaveformLayout(context: LayoutContext) {
sharedZoomDomain: sharedZoomDomain.value, sharedZoomDomain: sharedZoomDomain.value,
initialXDomain: props.initialXDomain ? initialXDomain.value : undefined, initialXDomain: props.initialXDomain ? initialXDomain.value : undefined,
initialXDomains: props.initialXDomains, initialXDomains: props.initialXDomains,
fixedYDomain: props.yDomain,
fixedYDomains: props.yDomains,
yDomains: yDomains:
props.displayMode === 'independent' props.displayMode === 'independent'
? Object.fromEntries( ? Object.fromEntries(
@@ -291,6 +284,7 @@ export function useWaveformLayout(context: LayoutContext) {
) )
: sharedYDomains.value, : sharedYDomains.value,
timeUnit: props.timeUnit, timeUnit: props.timeUnit,
xAxisLabelFormatter: props.axes?.x?.labelFormatter,
rendering: renderingOptions.value, rendering: renderingOptions.value,
hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels, hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
yAxisLabelX: yAxisMetrics.value.labelCenterX, yAxisLabelX: yAxisMetrics.value.labelCenterX,
@@ -321,7 +315,6 @@ export function useWaveformLayout(context: LayoutContext) {
) )
: [], : [],
) )
const xAxisTitleY = computed(() => innerHeight.value + X_AXIS_BAND + 10)
const editorSeries = computed<AnnotationSeriesInfo | undefined>(() => { const editorSeries = computed<AnnotationSeriesInfo | undefined>(() => {
const seriesId = annotationInteraction.editorDraft.value?.annotation.seriesId const seriesId = annotationInteraction.editorDraft.value?.annotation.seriesId
const series = chartSeries.value.find((item) => item.id === seriesId) const series = chartSeries.value.find((item) => item.id === seriesId)
@@ -365,7 +358,6 @@ export function useWaveformLayout(context: LayoutContext) {
resolveSeriesYScale, resolveSeriesYScale,
annotationTrackLayouts, annotationTrackLayouts,
renderedAnnotations, renderedAnnotations,
xAxisTitleY,
editorSeries, editorSeries,
resolveFrameNumber, resolveFrameNumber,
} }

View File

@@ -7,6 +7,7 @@ import {
TITLE_CHAR_WIDTH_RATIO, TITLE_CHAR_WIDTH_RATIO,
TITLE_DEFAULT_FONT_SIZE, TITLE_DEFAULT_FONT_SIZE,
TITLE_LINE_HEIGHT, TITLE_LINE_HEIGHT,
X_AXIS_TITLE_BOTTOM_OFFSET,
ZERO_LINE_DEFAULTS, ZERO_LINE_DEFAULTS,
} from './constants' } from './constants'
import { calculateRotatedTitleLayout, TITLE_AREA_HORIZONTAL_PADDING } from './title' import { calculateRotatedTitleLayout, TITLE_AREA_HORIZONTAL_PADDING } from './title'
@@ -49,6 +50,7 @@ export function useWaveformPresentation(context: PresentationContext) {
height: fixedHeight.value === undefined ? '100%' : `${fixedHeight.value}px`, height: fixedHeight.value === undefined ? '100%' : `${fixedHeight.value}px`,
})) }))
const isCleanView = computed(() => props.cleanView === true) const isCleanView = computed(() => props.cleanView === true)
const isPresentationMode = computed(() => props.presentationMode === true)
const resolvedZeroLine = computed(() => { const resolvedZeroLine = computed(() => {
const width = props.zeroLine.width const width = props.zeroLine.width
return { return {
@@ -143,11 +145,23 @@ export function useWaveformPresentation(context: PresentationContext) {
const titleAreaHeight = computed(() => const titleAreaHeight = computed(() =>
titleAreaReserved.value ? titleLayout.value.areaHeight : 0, titleAreaReserved.value ? titleLayout.value.areaHeight : 0,
) )
const chartTopMargin = computed(() => margin.top) const resolvePlotMargin = (value: number | undefined, fallback: number) =>
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
const resolvedPlotMargin = computed(() => ({
top: resolvePlotMargin(props.plotMargin.top, margin.top),
bottom: resolvePlotMargin(props.plotMargin.bottom, margin.bottom),
}))
const chartTopMargin = computed(() => resolvedPlotMargin.value.top)
const drawingHeight = computed(() => const drawingHeight = computed(() =>
Math.max(0, chartHeight.value - titleAreaHeight.value - paginationBandHeight.value), Math.max(0, chartHeight.value - titleAreaHeight.value - paginationBandHeight.value),
) )
const innerHeight = computed(() => Math.max(0, drawingHeight.value - margin.top - margin.bottom)) const xAxisTitleY = computed(() => Math.max(0, drawingHeight.value - X_AXIS_TITLE_BOTTOM_OFFSET))
const innerHeight = computed(() =>
Math.max(
0,
drawingHeight.value - resolvedPlotMargin.value.top - resolvedPlotMargin.value.bottom,
),
)
const titleAreaStyle = computed<CSSProperties>(() => ({ const titleAreaStyle = computed<CSSProperties>(() => ({
height: `${titleAreaHeight.value}px`, height: `${titleAreaHeight.value}px`,
justifyContent: justifyContent:
@@ -178,6 +192,7 @@ export function useWaveformPresentation(context: PresentationContext) {
chartHeight, chartHeight,
containerStyle, containerStyle,
isCleanView, isCleanView,
isPresentationMode,
resolvedZeroLine, resolvedZeroLine,
legendBackgroundColor, legendBackgroundColor,
legendInteractive, legendInteractive,
@@ -191,8 +206,10 @@ export function useWaveformPresentation(context: PresentationContext) {
titleMeasureStyle, titleMeasureStyle,
titleLayout, titleLayout,
titleAreaHeight, titleAreaHeight,
resolvedPlotMargin,
chartTopMargin, chartTopMargin,
drawingHeight, drawingHeight,
xAxisTitleY,
innerHeight, innerHeight,
titleAreaStyle, titleAreaStyle,
titleVisualStyle, titleVisualStyle,

View File

@@ -7,6 +7,7 @@ import type {
WaveformInteractionMode, WaveformInteractionMode,
WaveformLegendOptions, WaveformLegendOptions,
WaveformOverlayMode, WaveformOverlayMode,
WaveformPlotMargin,
WaveformPoint, WaveformPoint,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformTitleOptions, WaveformTitleOptions,
@@ -31,6 +32,8 @@ export interface WaveformChartProps {
minVisiblePoints?: number minVisiblePoints?: number
initialXDomain?: [number, number] initialXDomain?: [number, number]
initialXDomains?: Record<string, [number, number]> initialXDomains?: Record<string, [number, number]>
yDomain?: [number, number]
yDomains?: Record<string, [number, number]>
timeUnit?: 's' | 'ms' timeUnit?: 's' | 'ms'
frameNumber?: string | number frameNumber?: string | number
frameStyle?: WaveformFrameStyle frameStyle?: WaveformFrameStyle
@@ -40,11 +43,13 @@ export interface WaveformChartProps {
interactionMode?: WaveformInteractionMode interactionMode?: WaveformInteractionMode
grid?: WaveformGridOptions grid?: WaveformGridOptions
rendering?: WaveformRenderingOptions rendering?: WaveformRenderingOptions
plotMargin?: WaveformPlotMargin
title?: WaveformTitleOptions title?: WaveformTitleOptions
legend?: WaveformLegendOptions legend?: WaveformLegendOptions
hiddenSeriesIds?: string[] hiddenSeriesIds?: string[]
defaultHiddenSeriesIds?: string[] defaultHiddenSeriesIds?: string[]
cleanView?: boolean cleanView?: boolean
presentationMode?: boolean
zeroLine?: WaveformZeroLineOptions zeroLine?: WaveformZeroLineOptions
} }
@@ -62,9 +67,11 @@ type DefaultedProp =
| 'annotationsVisible' | 'annotationsVisible'
| 'grid' | 'grid'
| 'rendering' | 'rendering'
| 'plotMargin'
| 'legend' | 'legend'
| 'defaultHiddenSeriesIds' | 'defaultHiddenSeriesIds'
| 'cleanView' | 'cleanView'
| 'presentationMode'
| 'zeroLine' | 'zeroLine'
export type ResolvedWaveformChartProps = Readonly< export type ResolvedWaveformChartProps = Readonly<
@@ -88,16 +95,17 @@ export interface WaveformChartEmit {
(event: 'page-change', page: number, pageCount: number): void (event: 'page-change', page: number, pageCount: number): void
} }
export interface ViewportSelectionState { interface ViewportSelectionBase {
trackIndex: number trackIndex: number
independent: boolean independent: boolean
overlay: SVGRectElement
startX: number startX: number
startY: number startY: number
currentX: number currentX: number
currentY: number currentY: number
pointerId: number pointerId: number
mode: 'box' | 'pan'
xDomain: [number, number] xDomain: [number, number]
yDomains: Record<string, [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, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle, WaveformTitleTextStyle,
WaveformTitleOptions, WaveformTitleOptions,
WaveformLegendPosition, WaveformLegendPosition,

View File

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

View File

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

View File

@@ -23,6 +23,7 @@ interface HoverContext {
titleAreaHeight: ComputedRef<number> titleAreaHeight: ComputedRef<number>
chartTopMargin: ComputedRef<number> chartTopMargin: ComputedRef<number>
sharedOverlayElement: ShallowRef<SVGRectElement | undefined> sharedOverlayElement: ShallowRef<SVGRectElement | undefined>
isPresentationMode: ComputedRef<boolean>
updateViewportDrag: (event: PointerEvent) => void updateViewportDrag: (event: PointerEvent) => void
resolveTrackAtPointer: (pointerX: number, pointerY: number) => TrackLayout | undefined resolveTrackAtPointer: (pointerX: number, pointerY: number) => TrackLayout | undefined
} }
@@ -42,6 +43,7 @@ export function useWaveformHover(context: HoverContext) {
titleAreaHeight, titleAreaHeight,
chartTopMargin, chartTopMargin,
sharedOverlayElement, sharedOverlayElement,
isPresentationMode,
updateViewportDrag, updateViewportDrag,
resolveTrackAtPointer, resolveTrackAtPointer,
} = context } = context
@@ -62,6 +64,7 @@ export function useWaveformHover(context: HoverContext) {
trackIndex: number | null, trackIndex: number | null,
position: { x: number; y: number }, position: { x: number; y: number },
) => { ) => {
if (isPresentationMode.value) return
if (!hoveredPointsMatch(nextPoints)) hoverState.points = nextPoints if (!hoveredPointsMatch(nextPoints)) hoverState.points = nextPoints
hoverState.trackIndex = trackIndex hoverState.trackIndex = trackIndex
hoverState.position = position hoverState.position = position
@@ -73,6 +76,9 @@ export function useWaveformHover(context: HoverContext) {
hoverState.trackIndex = null hoverState.trackIndex = null
emit('point-hover', null) emit('point-hover', null)
} }
const handlePointerLeave = () => {
if (!isPresentationMode.value) clearHover()
}
const createHoveredSeriesPoint = ( const createHoveredSeriesPoint = (
series: DisplaySeries, series: DisplaySeries,
trackIndex: number, trackIndex: number,
@@ -103,6 +109,7 @@ export function useWaveformHover(context: HoverContext) {
series.points[pointBisector.center(series.points, xValue)] series.points[pointBisector.center(series.points, xValue)]
const handleIndependentPointerMove = (event: PointerEvent, trackIndex: number) => { const handleIndependentPointerMove = (event: PointerEvent, trackIndex: number) => {
if (isPresentationMode.value) return
if (selection.value?.trackIndex === trackIndex && selection.value.independent) { if (selection.value?.trackIndex === trackIndex && selection.value.independent) {
updateViewportDrag(event) updateViewportDrag(event)
return return
@@ -128,7 +135,8 @@ export function useWaveformHover(context: HoverContext) {
}) })
} }
const handleSharedPointerMove = (event: PointerEvent) => { const handleSharedPointerMove = (event: PointerEvent) => {
if (selection.value?.overlay === event.currentTarget) { if (isPresentationMode.value) return
if (selection.value && !selection.value.independent) {
updateViewportDrag(event) updateViewportDrag(event)
return return
} }
@@ -161,6 +169,7 @@ export function useWaveformHover(context: HoverContext) {
return { return {
cancelPendingHover, cancelPendingHover,
clearHover, clearHover,
handlePointerLeave,
beginAnnotationDrag, beginAnnotationDrag,
endAnnotationDrag, endAnnotationDrag,
handleIndependentPointerMove, handleIndependentPointerMove,

View File

@@ -1,19 +1,22 @@
import { pointer, scaleLinear, zoomIdentity, type ZoomTransform } from 'd3' 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 { MINIMUM_SELECTION_SIZE } from '../core/constants'
import type { DisplayTrack, TrackLayout } from '../core/types' import type { DisplayTrack, TrackLayout } from '../core/types'
import { hasFixedYDomainForTrack } from '../core/yDomain'
import type { import type {
ResolvedWaveformChartProps, ResolvedWaveformChartProps,
ViewportSelectionState, ViewportSelectionState,
WaveformChartEmit, WaveformChartEmit,
} from '../core/waveformChartTypes' } from '../core/waveformChartTypes'
import type { AnnotationSeriesCandidate } from '../annotation' import type { AnnotationSeriesCandidate } from '../annotation'
import type { ViewportInteractionStateMachine } from './viewportInteractionState'
interface ViewportContext { interface ViewportContext {
props: ResolvedWaveformChartProps props: ResolvedWaveformChartProps
emit: WaveformChartEmit emit: WaveformChartEmit
selection: Ref<ViewportSelectionState | null> selection: Ref<ViewportSelectionState | null>
viewportInteraction: ShallowRef<ViewportInteractionStateMachine>
spacePressed: Ref<boolean> spacePressed: Ref<boolean>
trackLayouts: ComputedRef<TrackLayout[]> trackLayouts: ComputedRef<TrackLayout[]>
chartTracks: ComputedRef<DisplayTrack[]> chartTracks: ComputedRef<DisplayTrack[]>
@@ -26,6 +29,7 @@ interface ViewportContext {
sharedYDomains: Ref<Record<string, [number, number]>> sharedYDomains: Ref<Record<string, [number, number]>>
independentYDomains: Ref<Record<number, [number, number]>> independentYDomains: Ref<Record<number, [number, number]>>
isZoomMode: ComputedRef<boolean> isZoomMode: ComputedRef<boolean>
isPresentationMode: ComputedRef<boolean>
editorSeriesOptions: Ref<AnnotationSeriesCandidate[]> editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
resolveInitialTrackDomain: (track: TrackLayout) => [number, number] resolveInitialTrackDomain: (track: TrackLayout) => [number, number]
canZoomTrack: (track: TrackLayout) => boolean canZoomTrack: (track: TrackLayout) => boolean
@@ -41,6 +45,7 @@ export function useWaveformViewport(context: ViewportContext) {
props, props,
emit, emit,
selection, selection,
viewportInteraction,
spacePressed, spacePressed,
trackLayouts, trackLayouts,
chartTracks, chartTracks,
@@ -53,6 +58,7 @@ export function useWaveformViewport(context: ViewportContext) {
sharedYDomains, sharedYDomains,
independentYDomains, independentYDomains,
isZoomMode, isZoomMode,
isPresentationMode,
editorSeriesOptions, editorSeriesOptions,
resolveInitialTrackDomain, resolveInitialTrackDomain,
canZoomTrack, canZoomTrack,
@@ -62,6 +68,10 @@ export function useWaveformViewport(context: ViewportContext) {
clearHover, clearHover,
resolveTrackAtPointer, resolveTrackAtPointer,
} = context } = context
const activeOverlay = shallowRef<SVGRectElement>()
const syncSelection = () => {
selection.value = viewportInteraction.value.state
}
const selectionBox = computed(() => { const selectionBox = computed(() => {
const active = selection.value const active = selection.value
if (!active) return null if (!active) return null
@@ -131,6 +141,7 @@ export function useWaveformViewport(context: ViewportContext) {
]), ]),
) )
const beginViewportDrag = (event: PointerEvent, trackIndex: number, independent: boolean) => { const beginViewportDrag = (event: PointerEvent, trackIndex: number, independent: boolean) => {
if (isPresentationMode.value) return
const panRequested = props.pannable && spacePressed.value const panRequested = props.pannable && spacePressed.value
if ((!props.zoomable && !panRequested) || !isZoomMode.value || event.button !== 0) return if ((!props.zoomable && !panRequested) || !isZoomMode.value || event.button !== 0) return
const overlay = event.currentTarget as SVGRectElement const overlay = event.currentTarget as SVGRectElement
@@ -139,19 +150,19 @@ export function useWaveformViewport(context: ViewportContext) {
const [rawX, rawY] = pointer(event, overlay) const [rawX, rawY] = pointer(event, overlay)
const x = Math.max(0, Math.min(independent ? track.width : innerWidth.value, rawX)) 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)) const y = Math.max(0, Math.min(independent ? track.height : innerHeight.value, rawY))
selection.value = { const started = viewportInteraction.value.begin({
trackIndex, trackIndex,
independent, independent,
overlay,
startX: x, startX: x,
startY: y, startY: y,
currentX: x,
currentY: y,
pointerId: event.pointerId, pointerId: event.pointerId,
mode: panRequested ? 'pan' : 'box', kind: panRequested ? 'pan' : 'box',
xDomain: track.xScale.domain() as [number, number], xDomain: track.xScale.domain() as [number, number],
yDomains: currentYDomains(), yDomains: currentYDomains(),
} })
if (!started) return
activeOverlay.value = overlay
syncSelection()
overlay.setPointerCapture?.(event.pointerId) overlay.setPointerCapture?.(event.pointerId)
clearHover() clearHover()
event.preventDefault() event.preventDefault()
@@ -187,9 +198,13 @@ export function useWaveformViewport(context: ViewportContext) {
const nextIndependentDomains = { ...independentYDomains.value } const nextIndependentDomains = { ...independentYDomains.value }
const nextSharedDomains = { ...sharedYDomains.value } const nextSharedDomains = { ...sharedYDomains.value }
targets.forEach((target) => { 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 key = target.series.trackId ?? target.series.id
const source = active.yDomains[key] ?? (target.yScale.domain() as [number, number]) 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 ySpan = source[1] - source[0]
const nextY = clampDomain( const nextY = clampDomain(
[source[0] + (dy / height) * ySpan, source[1] + (dy / height) * ySpan], [source[0] + (dy / height) * ySpan, source[1] + (dy / height) * ySpan],
@@ -203,28 +218,35 @@ export function useWaveformViewport(context: ViewportContext) {
emit('zoom-change', nextX) emit('zoom-change', nextX)
} }
const updateViewportDrag = (event: PointerEvent) => { const updateViewportDrag = (event: PointerEvent) => {
if (isPresentationMode.value) return
const active = selection.value 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) const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
if (!track) return if (!track) return
const [rawX, rawY] = pointer(event, active.overlay) const [rawX, rawY] = pointer(event, overlay)
active.currentX = Math.max( const currentX = Math.max(
0, 0,
Math.min(active.independent ? track.width : innerWidth.value, rawX), Math.min(active.independent ? track.width : innerWidth.value, rawX),
) )
active.currentY = Math.max( const currentY = Math.max(
0, 0,
Math.min(active.independent ? track.height : innerHeight.value, rawY), Math.min(active.independent ? track.height : innerHeight.value, rawY),
) )
selection.value = { ...active } const next = viewportInteraction.value.move(event.pointerId, { currentX, currentY })
if (active.mode === 'pan') applyPan(active, track) if (!next) return
selection.value = next
if (next.kind === 'pan') applyPan(next, track)
event.preventDefault() event.preventDefault()
} }
const cancelViewportDrag = (event?: PointerEvent) => { const cancelViewportDrag = (event?: PointerEvent) => {
const active = selection.value const active = selection.value
if (!active || (event && event.pointerId !== active.pointerId)) return if (!active || (event && event.pointerId !== active.pointerId)) return
active.overlay.releasePointerCapture?.(active.pointerId) activeOverlay.value?.releasePointerCapture?.(active.pointerId)
selection.value = null if (viewportInteraction.value.cancel(event?.pointerId)) {
activeOverlay.value = undefined
syncSelection()
}
} }
const applyBoxZoom = (active: ViewportSelectionState) => { const applyBoxZoom = (active: ViewportSelectionState) => {
const track = trackLayouts.value.find((item) => item.index === active.trackIndex) const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
@@ -285,17 +307,38 @@ export function useWaveformViewport(context: ViewportContext) {
void nextTick(configureZoom) void nextTick(configureZoom)
} }
const finishViewportDrag = (event: PointerEvent) => { const finishViewportDrag = (event: PointerEvent) => {
if (isPresentationMode.value) {
cancelViewportDrag()
return
}
const active = selection.value const active = selection.value
if (!active || event.pointerId !== active.pointerId) return if (!active || event.pointerId !== active.pointerId) return
updateViewportDrag(event) const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
active.overlay.releasePointerCapture?.(active.pointerId) if (!track) return
selection.value = null const overlay = activeOverlay.value
if (active.mode === 'pan') { 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) void nextTick(configureZoom)
return return
} }
if (Math.abs(active.currentX - active.startX) >= MINIMUM_SELECTION_SIZE) { if (Math.abs(completed.currentX - completed.startX) >= MINIMUM_SELECTION_SIZE) {
applyBoxZoom(active) applyBoxZoom(completed)
} }
} }
const resetViewport = (trackIndex?: number) => { const resetViewport = (trackIndex?: number) => {
@@ -319,7 +362,7 @@ export function useWaveformViewport(context: ViewportContext) {
void nextTick(configureZoom) void nextTick(configureZoom)
} }
const requestViewportReset = (event: MouseEvent) => { const requestViewportReset = (event: MouseEvent) => {
if (!props.zoomable || !isZoomMode.value) return if (isPresentationMode.value || !props.zoomable || !isZoomMode.value) return
event.preventDefault() event.preventDefault()
resetViewport() resetViewport()
emit('zoom-reset') emit('zoom-reset')

View File

@@ -3,6 +3,7 @@ import {
select, select,
zoom, zoom,
zoomIdentity, zoomIdentity,
zoomTransform,
type D3ZoomEvent, type D3ZoomEvent,
type ZoomBehavior, type ZoomBehavior,
type ZoomTransform, type ZoomTransform,
@@ -29,6 +30,7 @@ interface ZoomContext {
innerHeight: ComputedRef<number> innerHeight: ComputedRef<number>
hasChartArea: ComputedRef<boolean> hasChartArea: ComputedRef<boolean>
isZoomMode: ComputedRef<boolean> isZoomMode: ComputedRef<boolean>
isPresentationMode: ComputedRef<boolean>
resolveInitialTrackDomain: (track: TrackLayout) => [number, number] resolveInitialTrackDomain: (track: TrackLayout) => [number, number]
cancelPendingHover: () => void cancelPendingHover: () => void
} }
@@ -50,6 +52,7 @@ export function useWaveformZoom(context: ZoomContext) {
innerHeight, innerHeight,
hasChartArea, hasChartArea,
isZoomMode, isZoomMode,
isPresentationMode,
resolveInitialTrackDomain, resolveInitialTrackDomain,
cancelPendingHover, cancelPendingHover,
} = context } = context
@@ -67,7 +70,7 @@ export function useWaveformZoom(context: ZoomContext) {
const scheduleZoomCommit = () => zoomThrottle.schedule(commitPendingZoom) const scheduleZoomCommit = () => zoomThrottle.schedule(commitPendingZoom)
const handleSharedZoom = (event: D3ZoomEvent<SVGRectElement, unknown>) => { const handleSharedZoom = (event: D3ZoomEvent<SVGRectElement, unknown>) => {
if (synchronizingZoomTransform) return if (synchronizingZoomTransform || isPresentationMode.value) return
cancelPendingHover() cancelPendingHover()
pendingSharedZoomTransform = event.transform pendingSharedZoomTransform = event.transform
pendingSharedZoomGesture = 'wheel' pendingSharedZoomGesture = 'wheel'
@@ -78,7 +81,7 @@ export function useWaveformZoom(context: ZoomContext) {
event: D3ZoomEvent<SVGRectElement, unknown>, event: D3ZoomEvent<SVGRectElement, unknown>,
trackIndex: number, trackIndex: number,
) => { ) => {
if (synchronizingZoomTransform) return if (synchronizingZoomTransform || isPresentationMode.value) return
cancelPendingHover() cancelPendingHover()
pendingIndependentZoomTransforms.set(trackIndex, event.transform) pendingIndependentZoomTransforms.set(trackIndex, event.transform)
pendingIndependentZoomGestures.set(trackIndex, 'wheel') pendingIndependentZoomGestures.set(trackIndex, 'wheel')
@@ -86,6 +89,10 @@ export function useWaveformZoom(context: ZoomContext) {
scheduleWheelZoomEnd() scheduleWheelZoomEnd()
} }
function commitPendingZoom() { function commitPendingZoom() {
if (isPresentationMode.value) {
cancelPendingZoom()
return
}
if (pendingSharedZoomTransform) { if (pendingSharedZoomTransform) {
const transform = pendingSharedZoomTransform const transform = pendingSharedZoomTransform
pendingSharedZoomTransform = null pendingSharedZoomTransform = null
@@ -231,9 +238,24 @@ export function useWaveformZoom(context: ZoomContext) {
const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries) const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
return tracks.length > 0 && tracks.every(canZoomTrack) 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 = () => { const configureZoom = () => {
clearZoomBindings() clearZoomBindings()
if (!props.zoomable || !isZoomMode.value || !hasChartArea.value || !trackLayouts.value.length) { if (
isPresentationMode.value ||
!props.zoomable ||
!isZoomMode.value ||
!hasChartArea.value ||
!trackLayouts.value.length
) {
return return
} }
if (props.displayMode === 'independent') { if (props.displayMode === 'independent') {
@@ -244,10 +266,7 @@ export function useWaveformZoom(context: ZoomContext) {
if (!overlay) return if (!overlay) return
const dataDomain = resolveInitialTrackDomain(track) const dataDomain = resolveInitialTrackDomain(track)
const behavior = zoom<SVGRectElement, unknown>() const behavior = zoom<SVGRectElement, unknown>()
.filter( .filter((event) => canHandleWheelZoom(event, canZoomTrack(track)))
(event) =>
event.type === 'wheel' && (event as WheelEvent).deltaY < 0 && canZoomTrack(track),
)
.scaleExtent([1, resolveMaximumZoomScale(dataDomain)]) .scaleExtent([1, resolveMaximumZoomScale(dataDomain)])
.extent([ .extent([
[0, 0], [0, 0],
@@ -275,10 +294,7 @@ export function useWaveformZoom(context: ZoomContext) {
const overlay = sharedOverlayElement.value const overlay = sharedOverlayElement.value
if (!overlay) return if (!overlay) return
const behavior = zoom<SVGRectElement, unknown>() const behavior = zoom<SVGRectElement, unknown>()
.filter( .filter((event) => canHandleWheelZoom(event, canZoomSharedTracks()))
(event) =>
event.type === 'wheel' && (event as WheelEvent).deltaY < 0 && canZoomSharedTracks(),
)
.scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value)]) .scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value)])
.extent([ .extent([
[0, 0], [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

@@ -70,6 +70,11 @@
cursor: crosshair; cursor: crosshair;
} }
.waveform-track__overlay.is-presentation {
cursor: default;
pointer-events: none;
}
.waveform-track__zero-line { .waveform-track__zero-line {
fill: none; fill: none;
pointer-events: none; pointer-events: none;

View File

@@ -13,6 +13,7 @@ interface Props {
clipPathId: string clipPathId: string
innerWidth: number innerWidth: number
zoomable: boolean zoomable: boolean
interactive?: boolean
displayMode: WaveformDisplayMode displayMode: WaveformDisplayMode
interactionMode?: WaveformInteractionMode interactionMode?: WaveformInteractionMode
frameNumber?: string | number frameNumber?: string | number
@@ -38,6 +39,7 @@ interface Emits {
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
interactionMode: 'zoom', interactionMode: 'zoom',
interactive: true,
cleanView: false, cleanView: false,
zeroLine: () => ({ visible: false, color: '#98a2b3', width: 1, dash: '6 4' }), zeroLine: () => ({ visible: false, color: '#98a2b3', width: 1, dash: '6 4' }),
}) })
@@ -92,7 +94,7 @@ const resolvedFrameStyle = computed(() => {
/> />
<rect <rect
v-if="!track.isEmpty && !cleanView" v-if="!track.isEmpty"
class="waveform-track__plot-frame waveform-chart__plot-frame" class="waveform-track__plot-frame waveform-chart__plot-frame"
:width="track.width ?? innerWidth" :width="track.width ?? innerWidth"
:height="track.height" :height="track.height"
@@ -116,9 +118,11 @@ const resolvedFrameStyle = computed(() => {
v-if="!track.isEmpty && track.hasVisibleSeries && displayMode === 'independent'" v-if="!track.isEmpty && track.hasVisibleSeries && displayMode === 'independent'"
class="waveform-track__overlay waveform-track__overlay--independent waveform-chart__overlay waveform-chart__overlay--independent" class="waveform-track__overlay waveform-track__overlay--independent waveform-chart__overlay waveform-chart__overlay--independent"
:class="{ :class="{
'is-zoomable': zoomable && interactionMode === 'zoom', 'is-zoomable': interactive && zoomable && interactionMode === 'zoom',
'is-annotating': interactionMode === 'annotation', 'is-annotating': interactive && interactionMode === 'annotation',
'is-presentation': !interactive,
}" }"
:data-interactive="interactive"
:data-independent-overlay-index="track.index" :data-independent-overlay-index="track.index"
:width="track.width ?? innerWidth" :width="track.width ?? innerWidth"
:height="track.height" :height="track.height"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -33,10 +33,10 @@ describe('WaveformChart', () => {
tracks[0].get('.waveform-chart__y-axis-label-bg').attributes('x'), tracks[0].get('.waveform-chart__y-axis-label-bg').attributes('x'),
) )
expect(labelX).toBe(-103) expect(labelX).toBe(-74)
expect(labelBackgroundX).toBe(labelX - 12) expect(labelBackgroundX).toBe(labelX - 12)
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(119) expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(90)
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(119) expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(90)
}) })
it('keeps a tick-only gutter when channel labels are empty', async () => { it('keeps a tick-only gutter when channel labels are empty', async () => {
@@ -62,8 +62,8 @@ describe('WaveformChart', () => {
const secondLeft = Number(tracks[1].attributes('data-track-left')) const secondLeft = Number(tracks[1].attributes('data-track-left'))
expect(wrapper.findAll('.waveform-chart__y-axis-label')).toHaveLength(0) expect(wrapper.findAll('.waveform-chart__y-axis-label')).toHaveLength(0)
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(89) expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(64)
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(89) expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(60)
}) })
it('keeps the Y-axis label gutter stable while paging between value ranges', async () => { it('keeps the Y-axis label gutter stable while paging between value ranges', async () => {
@@ -122,7 +122,7 @@ describe('WaveformChart', () => {
const tracks = wrapper.findAll('.waveform-chart__track') const tracks = wrapper.findAll('.waveform-chart__track')
const firstWidth = Number(tracks[0].attributes('data-track-width')) const firstWidth = Number(tracks[0].attributes('data-track-width'))
const secondLeft = Number(tracks[1].attributes('data-track-left')) const secondLeft = Number(tracks[1].attributes('data-track-left'))
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(39) expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(32)
}) })
it('uses one shared overlay and bottom-row x axes for separated and compact grids', async () => { it('uses one shared overlay and bottom-row x axes for separated and compact grids', async () => {
@@ -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( const wrapper = await mountSizedChart(
{ {
kind: 'series', kind: 'series',
@@ -262,8 +262,10 @@ describe('WaveformChart', () => {
) )
const tracks = wrapper.findAll('.waveform-chart__track') const tracks = wrapper.findAll('.waveform-chart__track')
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('6.00') expect(tracks[0]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8000')
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5.00') expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000')
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-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 () => { 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 () => { it('keeps annotations bound to their channel while paging', async () => {

View File

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

View File

@@ -54,8 +54,9 @@ describe('WaveformChart', () => {
], ],
}) })
expect(first.find('.waveform-chart__axis-exponent--y').exists()).toBe(false) expect(first.get('.waveform-chart__axis--y').text()).not.toContain('E')
expect(second.get('.waveform-chart__axis-exponent--y').text()).toBe('E+04') expect(second.get('.waveform-chart__axis--y').text()).toContain('E+04 ')
expect(second.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
}) })
it('renders a configurable zero line only when the Y domain contains zero', async () => { it('renders a configurable zero line only when the Y domain contains zero', async () => {
@@ -146,7 +147,7 @@ describe('WaveformChart', () => {
kind: 'points', kind: 'points',
points: [ points: [
{ x: 0, y: -1 }, { x: 0, y: -1 },
{ x: 1, y: 1 }, { x: 10_000, y: 1 },
], ],
}, },
}, },
@@ -227,8 +228,8 @@ describe('WaveformChart', () => {
data: { data: {
kind: 'points', kind: 'points',
points: [ points: [
{ x: 0, y: 1 }, { x: 0, y: 10_000 },
{ x: 1, y: 2 }, { x: 10_000, y: 20_000 },
], ],
}, },
}, },
@@ -239,7 +240,7 @@ describe('WaveformChart', () => {
kind: 'points', kind: 'points',
points: [ points: [
{ x: 0, y: 2 }, { 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' }], annotations: [{ id: 'note', seriesId: 'first', x: 0.5, y: 0, text: 'hidden note' }],
zeroLine: { visible: true }, zeroLine: { visible: true },
title: { text: 'hidden title' }, 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 regularWrapper = await mountSizedChart(data, sharedProps)
const wrapper = await mountSizedChart(data, { const wrapper = await mountSizedChart(data, {
@@ -285,13 +288,32 @@ describe('WaveformChart', () => {
expect(wrapper.find('.waveform-chart__overlay--independent').exists()).toBe(true) expect(wrapper.find('.waveform-chart__overlay--independent').exists()).toBe(true)
expect(wrapper.get('.waveform-chart__title-area').attributes('aria-hidden')).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__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__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__plot-background').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__watermark').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__legend-layer').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__label').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-chart__zero-line').exists()).toBe(false)
expect(wrapper.find('.waveform-annotation-layer').exists()).toBe(false) expect(wrapper.find('.waveform-annotation-layer').exists()).toBe(false)
expect(wrapper.find('.ant-pagination').exists()).toBe(false) expect(wrapper.find('.ant-pagination').exists()).toBe(false)

View File

@@ -0,0 +1,306 @@
import { flushPromises } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { flushAnimationFrames } from '../../test/setup'
import { mountSizedChart } from '../../test/waveformChart'
import type { WaveformData, WaveformDisplayMode } from '../data/types'
const presentationData: WaveformData = {
kind: 'series',
series: [
{
id: 'low',
trackId: 'shared',
name: '低量程',
data: {
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 10 },
{ x: 2, y: 5 },
],
},
},
{
id: 'high',
trackId: 'shared',
name: '高量程',
data: {
kind: 'points',
points: [
{ x: 0, y: 100 },
{ x: 1, y: 200 },
{ x: 2, y: 150 },
],
},
},
{
id: 'other',
name: '其他通道',
data: {
kind: 'points',
points: [
{ x: 0, y: -1 },
{ x: 1, y: 1 },
{ x: 2, y: 0 },
],
},
},
],
}
function setOverlayBounds(overlay: ReturnType<Awaited<ReturnType<typeof mountSizedChart>>['get']>) {
const width = Number(overlay.attributes('width'))
const height = Number(overlay.attributes('height'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height }),
})
return { width, height }
}
function dispatchPointer(
element: Element,
type: string,
options: MouseEventInit & { pointerId: number },
) {
const event = new MouseEvent(type, { bubbles: true, cancelable: true, ...options })
Object.defineProperty(event, 'pointerId', { value: options.pointerId })
element.dispatchEvent(event)
}
describe('WaveformChart presentation mode', () => {
it('keeps existing plot interaction enabled by default', async () => {
const wrapper = await mountSizedChart(presentationData, {
grid: { rowCount: 1, columnCount: 1 },
})
const overlay = wrapper.get('.waveform-chart__overlay--independent')
const { width, height } = setOverlayBounds(overlay)
expect(wrapper.attributes('data-presentation-mode')).toBe('false')
expect(overlay.classes()).toContain('is-zoomable')
overlay.element.dispatchEvent(
new MouseEvent('pointermove', {
clientX: width / 2,
clientY: height / 2,
bubbles: true,
}),
)
flushAnimationFrames()
await flushPromises()
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
expect(wrapper.emitted('point-hover')?.length).toBeGreaterThan(0)
})
it.each<WaveformDisplayMode>(['independent', 'separated'])(
'blocks plot mouse events in %s layout',
async (displayMode) => {
const wrapper = await mountSizedChart(presentationData, {
annotations: [{ id: 'note', seriesId: 'low', x: 1, y: 10, text: '只读标注' }],
displayMode,
grid: { rowCount: 1, columnCount: 1 },
interactionMode: 'annotation',
presentationMode: true,
})
const overlay = wrapper.get('.waveform-chart__overlay')
const { width, height } = setOverlayBounds(overlay)
expect(wrapper.attributes('data-presentation-mode')).toBe('true')
expect(overlay.classes()).not.toContain('is-zoomable')
expect(overlay.classes()).not.toContain('is-annotating')
overlay.element.dispatchEvent(
new MouseEvent('pointermove', {
clientX: width / 2,
clientY: height / 2,
bubbles: true,
}),
)
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -4000,
clientX: width / 2,
clientY: height / 2,
bubbles: true,
cancelable: true,
}),
)
overlay.element.dispatchEvent(
new MouseEvent('click', {
clientX: width / 2,
clientY: height / 2,
bubbles: true,
cancelable: true,
}),
)
overlay.element.dispatchEvent(
new MouseEvent('contextmenu', {
clientX: width / 2,
clientY: height / 2,
bubbles: true,
cancelable: true,
}),
)
wrapper.get('.waveform-chart__svg').element.dispatchEvent(
new MouseEvent('dblclick', { bubbles: true, cancelable: true }),
)
await wrapper.get('[data-annotation-id="note"]').trigger('contextmenu', {
clientX: width / 2,
clientY: height / 2,
})
const annotation = wrapper.get('[data-annotation-id="note"]').element
dispatchPointer(annotation, 'pointerdown', {
button: 0,
clientX: width / 2,
clientY: height / 2,
pointerId: 42,
})
dispatchPointer(annotation, 'pointermove', {
clientX: width / 2 + 30,
clientY: height / 2 + 20,
pointerId: 42,
})
dispatchPointer(annotation, 'pointerup', {
clientX: width / 2 + 30,
clientY: height / 2 + 20,
pointerId: 42,
})
flushAnimationFrames()
await flushPromises()
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(false)
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(false)
expect(wrapper.find('.waveform-annotation-context-menu').exists()).toBe(false)
expect(wrapper.emitted('point-hover')).toBeUndefined()
expect(wrapper.emitted('zoom-change')).toBeUndefined()
expect(wrapper.emitted('zoom-end')).toBeUndefined()
expect(wrapper.emitted('zoom-reset')).toBeUndefined()
expect(wrapper.emitted('update:annotations')).toBeUndefined()
},
)
it('keeps the legend and pagination interactive', async () => {
const wrapper = await mountSizedChart(presentationData, {
grid: { rowCount: 1, columnCount: 1, showPagination: true },
legend: { interactive: true },
presentationMode: true,
})
await wrapper.findAll('.waveform-chart__legend-item')[1]!.trigger('click')
await flushPromises()
expect(wrapper.emitted('update:hidden-series-ids')?.at(-1)).toEqual([['high']])
await wrapper.get('.ant-pagination-next button').trigger('click')
await flushPromises()
expect(wrapper.emitted('page-change')?.at(-1)).toEqual([2, 2])
})
it('cleans active UI state and restores interaction without resetting the viewport', async () => {
const wrapper = await mountSizedChart(presentationData, {
grid: { rowCount: 1, columnCount: 1 },
})
const overlay = wrapper.get('.waveform-chart__overlay--independent')
const { width, height } = setOverlayBounds(overlay)
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -4000,
clientX: width / 2,
clientY: height / 2,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
const zoomedEndpoint = wrapper.get('.waveform-chart__axis-endpoint--end').text()
dispatchPointer(overlay.element, 'pointerdown', {
button: 0,
clientX: width * 0.25,
clientY: height / 2,
pointerId: 41,
})
dispatchPointer(overlay.element, 'pointermove', {
clientX: width * 0.75,
clientY: height / 2,
pointerId: 41,
})
await flushPromises()
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(true)
await wrapper.setProps({ presentationMode: true })
await flushPromises()
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(false)
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe(zoomedEndpoint)
const zoomEventCount = wrapper.emitted('zoom-change')?.length ?? 0
await wrapper.setProps({ presentationMode: false })
await flushPromises()
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: 200,
clientX: width / 2,
clientY: height / 2,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
expect(wrapper.emitted('zoom-change')?.length).toBeGreaterThan(zoomEventCount)
})
it('closes hover and annotation UI when enabled at runtime', async () => {
const wrapper = await mountSizedChart(presentationData, {
grid: { rowCount: 1, columnCount: 1 },
interactionMode: 'annotation',
})
const overlay = wrapper.get('.waveform-chart__overlay--independent')
const { width, height } = setOverlayBounds(overlay)
overlay.element.dispatchEvent(
new MouseEvent('pointermove', {
clientX: width / 2,
clientY: height / 2,
bubbles: true,
}),
)
flushAnimationFrames()
await flushPromises()
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
overlay.element.dispatchEvent(
new MouseEvent('contextmenu', {
clientX: width / 2,
clientY: height / 2,
bubbles: true,
cancelable: true,
}),
)
await flushPromises()
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(true)
await wrapper.setProps({ presentationMode: true })
await flushPromises()
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(false)
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(false)
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null])
await wrapper.setProps({ presentationMode: false })
overlay.element.dispatchEvent(
new MouseEvent('pointermove', {
clientX: width / 2,
clientY: height / 2,
bubbles: true,
}),
)
flushAnimationFrames()
await flushPromises()
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
})
})

View File

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

View File

@@ -38,6 +38,21 @@ describe('WaveformChart', () => {
expect(domain[1]).toBeLessThanOrEqual(1) expect(domain[1]).toBeLessThanOrEqual(1)
expect(endpoints()[0]).not.toBe(initialEndpoints[0]) expect(endpoints()[0]).not.toBe(initialEndpoints[0])
expect(endpoints()[1]).toBe(initialEndpoints[1]) 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 () => { 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' })) 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 () => { it('does not activate pannable on a chart that the pointer is outside', async () => {
const data: WaveformData = { const data: WaveformData = {
kind: 'points', kind: 'points',

View File

@@ -79,8 +79,8 @@ describe('WaveformChart', () => {
expect(wrapper.emitted('zoom-change')).toBeUndefined() expect(wrapper.emitted('zoom-change')).toBeUndefined()
expect(wrapper.emitted('zoom-end')).toBeUndefined() expect(wrapper.emitted('zoom-end')).toBeUndefined()
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0.00') expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00') expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
}) })
it('ignores independent viewport dragging', async () => { it('ignores independent viewport dragging', async () => {
@@ -207,7 +207,7 @@ describe('WaveformChart', () => {
expect(secondDomain[1] - secondDomain[0]).toBeCloseTo(10) 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( const wrapper = await mountSizedChart(
{ {
kind: 'points', kind: 'points',
@@ -256,9 +256,57 @@ describe('WaveformChart', () => {
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0 const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
await dispatchWheel(4000) 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(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 () => { it('resets a shared viewport on double-click and emits zoom-reset', async () => {
@@ -285,14 +333,14 @@ describe('WaveformChart', () => {
) )
flushAnimationFrames() flushAnimationFrames()
await flushPromises() await flushPromises()
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe('0.00') expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe('0')
overlay.element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })) overlay.element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
await flushPromises() await flushPromises()
expect(wrapper.emitted('zoom-reset')).toHaveLength(1) expect(wrapper.emitted('zoom-reset')).toHaveLength(1)
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0.00') expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00') expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
}) })
it('exposes resetViewport for independent tracks', async () => { it('exposes resetViewport for independent tracks', async () => {
@@ -316,13 +364,13 @@ describe('WaveformChart', () => {
) )
flushAnimationFrames() flushAnimationFrames()
await flushPromises() await flushPromises()
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).not.toBe('0.00') expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).not.toBe('0')
const chart = wrapper.vm as unknown as { resetViewport: () => void } const chart = wrapper.vm as unknown as { resetViewport: () => void }
chart.resetViewport() chart.resetViewport()
await flushPromises() await flushPromises()
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0.00') expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0')
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1.00') expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1000')
}) })
}) })

View File

@@ -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 type { WaveformPoint } from '@/types'
import { resolveWaveformPointErrors } from './data' import { resolveWaveformPointErrors } from './data'
import type { ResolvedWaveformRenderingOptions } from './renderingOptions' import type { ResolvedWaveformRenderingOptions } from './renderingOptions'
import {
resolveRenderablePointSelectionStrategy,
type VisiblePointRange,
} from './renderingStrategies'
export { export {
DEFAULT_WAVEFORM_RENDERING_OPTIONS, DEFAULT_WAVEFORM_RENDERING_OPTIONS,
@@ -13,11 +17,6 @@ export {
const pointBisector = bisector((point: WaveformPoint) => point.x) const pointBisector = bisector((point: WaveformPoint) => point.x)
const acceptAllPoints = () => true const acceptAllPoints = () => true
interface VisiblePointRange {
start: number
end: number
}
interface PointSeriesSource { interface PointSeriesSource {
points: WaveformPoint[] points: WaveformPoint[]
} }
@@ -65,10 +64,6 @@ export function hasMinimumVisibleXValues(
return false return false
} }
function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefined) {
if (point && target[target.length - 1] !== point) target.push(point)
}
function selectRenderablePointsInRange( function selectRenderablePointsInRange(
points: WaveformPoint[], points: WaveformPoint[],
range: VisiblePointRange, range: VisiblePointRange,
@@ -76,82 +71,17 @@ function selectRenderablePointsInRange(
width: number, width: number,
options: ResolvedWaveformRenderingOptions, options: ResolvedWaveformRenderingOptions,
): WaveformPoint[] { ): 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 start = Math.max(0, range.start - 1)
const end = Math.min(points.length, range.end + 1) const end = Math.min(points.length, range.end + 1)
const visibleCount = end - start const visibleCount = end - start
if (visibleCount <= 0) return [] if (visibleCount <= 0) return []
if (!options.downsample || visibleCount <= options.downsampleThreshold) { return resolveRenderablePointSelectionStrategy({ visibleCount, width, options }).select({
return points.slice(start, end) points,
} range,
domain,
const maximumPointCount = Math.max(4, Math.floor(width * options.maxPointsPerPixel)) width,
const bucketCount = Math.max(1, Math.floor(maximumPointCount / 4)) options,
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
} }
/** /**

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

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

View File

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

View File

@@ -46,6 +46,10 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
<span>净图</span> <span>净图</span>
<Switch v-model:checked="model.cleanView" size="small" aria-label="净图模式" /> <Switch v-model:checked="model.cleanView" size="small" aria-label="净图模式" />
</label> </label>
<label class="frame-style-control frame-style-control--switch">
<span>展示模式</span>
<Switch v-model:checked="model.presentationMode" size="small" aria-label="展示模式" />
</label>
</div> </div>
</section> </section>
<section class="control-section"> <section class="control-section">

View File

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

View File

@@ -10,10 +10,12 @@ import type {
WaveformLegendPosition, WaveformLegendPosition,
WaveformLineStyle, WaveformLineStyle,
WaveformOverlayMode, WaveformOverlayMode,
WaveformPlotMargin,
WaveformTitleOptions, WaveformTitleOptions,
WaveformZeroLineOptions, WaveformZeroLineOptions,
WaveformZoomEndPayload, WaveformZoomEndPayload,
} from '../components' } from '../components'
import type { DemoXAxisLabelFormat, DemoXAxisLabelTimeZone } from './useDemoXAxisLabelControls'
interface SelectOption<T> { interface SelectOption<T> {
label: string label: string
@@ -25,7 +27,10 @@ export interface DemoControlPanelModel {
displayMode: WaveformDisplayMode displayMode: WaveformDisplayMode
overlayMode: WaveformOverlayMode overlayMode: WaveformOverlayMode
showTooltip: boolean showTooltip: boolean
plotMarginTop: number
plotMarginBottom: number
cleanView: boolean cleanView: boolean
presentationMode: boolean
selectedSeriesId: string selectedSeriesId: string
selectedLineStyle: WaveformLineStyle selectedLineStyle: WaveformLineStyle
lineStyleOptions: Array<SelectOption<WaveformLineStyle>> lineStyleOptions: Array<SelectOption<WaveformLineStyle>>
@@ -43,6 +48,14 @@ export interface DemoControlPanelModel {
verticalGridColor: string verticalGridColor: string
xAxisLineVisible: boolean xAxisLineVisible: boolean
yAxisLineVisible: boolean yAxisLineVisible: boolean
xAxisLabelFormatterEnabled: boolean
xAxisLabelFormat: DemoXAxisLabelFormat
xAxisLabelFormatOptions: Array<SelectOption<DemoXAxisLabelFormat>>
xAxisLabelMultiplier: number
xAxisLabelFractionDigits: number
xAxisLabelTimeZone: DemoXAxisLabelTimeZone
xAxisLabelTimeZoneOptions: Array<SelectOption<DemoXAxisLabelTimeZone>>
xAxisLabelShowMilliseconds: boolean
frameBorderColor: string frameBorderColor: string
frameBackgroundColor: string frameBackgroundColor: string
frameBorderWidth: number frameBorderWidth: number
@@ -87,7 +100,9 @@ export interface DemoChartModel {
frameStyle: WaveformFrameStyle frameStyle: WaveformFrameStyle
axes: WaveformAxesOptions axes: WaveformAxesOptions
cleanView: boolean cleanView: boolean
presentationMode: boolean
showTooltip: boolean showTooltip: boolean
plotMargin: WaveformPlotMargin
zeroLine: WaveformZeroLineOptions zeroLine: WaveformZeroLineOptions
frameWatermarkVisible: boolean frameWatermarkVisible: boolean
annotations: WaveformAnnotation[] annotations: WaveformAnnotation[]

View File

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

View File

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

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, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle, WaveformTitleTextStyle,
WaveformTitleOptions, WaveformTitleOptions,
WaveformLegendPosition, WaveformLegendPosition,
WaveformLegendOrientation, WaveformLegendOrientation,
WaveformLegendOptions, WaveformLegendOptions,
WaveformFrameStyle, WaveformFrameStyle,
WaveformXAxisLabelKind,
WaveformXAxisLabelFormatterContext,
WaveformXAxisLabelFormatter,
WaveformAxesOptions, WaveformAxesOptions,
WaveformZeroLineOptions, WaveformZeroLineOptions,
// 数据类型 // 数据类型

View File

@@ -1,7 +1,9 @@
import { createApp } from 'vue' import { createApp } from 'vue'
import 'ant-design-vue/dist/antd.css' 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' 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); grid-template-columns: 280px minmax(0, 1fr);
gap: 12px; gap: 12px;
width: 100%; width: 100%;
height: 100vh; height: 100%;
height: 100dvh;
padding: 12px; padding: 12px;
overflow: hidden; overflow: hidden;
} }
@@ -134,6 +133,14 @@ body {
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
} }
.x-axis-label-controls {
display: grid;
gap: 10px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #eaecf0;
}
.grid-line-color-picker, .grid-line-color-picker,
.grid-line-color-picker .vc-color-wrap { .grid-line-color-picker .vc-color-wrap {
width: 48px; width: 48px;
@@ -188,6 +195,7 @@ body {
} }
.frame-style-controls, .frame-style-controls,
.plot-margin-controls,
.auxiliary-style-controls { .auxiliary-style-controls {
display: grid; display: grid;
gap: 10px; gap: 10px;

View File

@@ -74,6 +74,14 @@ export interface WaveformRenderingOptions {
errorBarMinSpacing?: number errorBarMinSpacing?: number
} }
/** Pixel margins reserved above and below the waveform plotting area. */
export interface WaveformPlotMargin {
/** Space between the top of the drawing SVG and the plotting area. Defaults to 18. */
top?: number
/** Space between the plotting area and the bottom of the drawing SVG. Defaults to 52. */
bottom?: number
}
/** Text styling for the chart-level title. */ /** Text styling for the chart-level title. */
export interface WaveformTitleTextStyle { export interface WaveformTitleTextStyle {
color?: string color?: string
@@ -121,10 +129,31 @@ export interface WaveformFrameStyle {
backgroundColor?: string backgroundColor?: string
} }
export type WaveformXAxisLabelKind = 'tick' | 'start' | 'end'
/** Context passed to custom X-axis label formatters. */
export interface WaveformXAxisLabelFormatterContext {
kind: WaveformXAxisLabelKind
/** X coordinate in the source data, before time-unit conversion. */
rawValue: number
timeUnit: 's' | 'ms'
/** Current visible X domain in source coordinates. */
domain: [number, number]
/** Current visible X domain converted to the selected display unit. */
displayDomain: [number, number]
}
export type WaveformXAxisLabelFormatter = (
value: number,
context: WaveformXAxisLabelFormatterContext,
) => string
/** Controls axis baseline visibility while preserving tick marks and axis text. */ /** Controls axis baseline visibility while preserving tick marks and axis text. */
export interface WaveformAxesOptions { export interface WaveformAxesOptions {
x?: { x?: {
lineVisible?: boolean lineVisible?: boolean
/** Formats display-unit X values for ticks and visible-range endpoints. */
labelFormatter?: WaveformXAxisLabelFormatter
} }
y?: { y?: {
lineVisible?: boolean lineVisible?: boolean

View File

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

View File

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

View File

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

View File

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

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>