Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a12002ba11 | ||
|
|
6cbb1e7668 | ||
|
|
f97ff24cdf |
50
README.md
50
README.md
@@ -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 轴范围,以及全局、按轨道或按系列配置的固定振幅范围
|
||||||
- 按轨道控制水平/垂直网格线的显隐与颜色
|
- 按轨道控制水平/垂直网格线的显隐与颜色
|
||||||
- 受控标注、右键编辑、拖拽避让和自定义颜色
|
- 受控标注、右键编辑、拖拽避让和自定义颜色
|
||||||
- 标题、图框、坐标轴、零值参考线、净图和渲染参数可配置
|
- 标题、图框、坐标轴、零值参考线、净图和渲染参数可配置
|
||||||
@@ -107,6 +111,8 @@ 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 轴基线显隐 |
|
||||||
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
|
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
|
||||||
@@ -114,6 +120,7 @@ const data = ref<WaveformData>({
|
|||||||
| `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'` | 左键交互模式 |
|
||||||
@@ -176,6 +183,38 @@ 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 模式下按住鼠标左键拖拽,松开后同时缩放
|
||||||
@@ -471,6 +510,14 @@ tooltip 仍然可用,切换回普通模式后原有配置和标注不会丢失
|
|||||||
`interactionMode` 可选 `zoom` 或 `annotation`,默认使用缩放模式。右键绘图区可直接打开
|
`interactionMode` 可选 `zoom` 或 `annotation`,默认使用缩放模式。右键绘图区可直接打开
|
||||||
标注编辑器,无需切换交互模式。`zoomable`、`pannable` 和 `showTooltip` 可分别控制缩放、
|
标注编辑器,无需切换交互模式。`zoomable`、`pannable` 和 `showTooltip` 可分别控制缩放、
|
||||||
空格拖拽平移和 tooltip;平移默认关闭。
|
空格拖拽平移和 tooltip;平移默认关闭。
|
||||||
|
|
||||||
|
展示场景可启用 `presentationMode`,统一禁用绘图区的 tooltip、缩放、平移、双击复位和
|
||||||
|
标注交互。该模式不会隐藏任何图形内容,也不会禁用图例切换或分页;关闭后恢复原交互配置。
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<WaveformChart :data="chartData" :presentation-mode="true" />
|
||||||
|
```
|
||||||
|
|
||||||
空数据或过滤后没有有效点时,组件会保留图框布局并显示“暂无有效波形数据”。
|
空数据或过滤后没有有效点时,组件会保留图框布局并显示“暂无有效波形数据”。
|
||||||
|
|
||||||
## 大数据渲染
|
## 大数据渲染
|
||||||
@@ -580,7 +627,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` 为固定振幅范围示例
|
||||||
|
|
||||||
## 本地开发
|
## 本地开发
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "waveform-analysis",
|
"name": "waveform-analysis",
|
||||||
"version": "0.1.22",
|
"version": "0.1.25",
|
||||||
"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
18
pnpm-lock.yaml
generated
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ const yAxisLineVisible = ref(false)
|
|||||||
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 zeroLineVisible = ref(false)
|
const zeroLineVisible = ref(false)
|
||||||
const zeroLineColor = ref('#98a2b3')
|
const zeroLineColor = ref('#98a2b3')
|
||||||
@@ -289,6 +290,7 @@ const controlPanelModel = reactive({
|
|||||||
overlayMode,
|
overlayMode,
|
||||||
showTooltip,
|
showTooltip,
|
||||||
cleanView,
|
cleanView,
|
||||||
|
presentationMode,
|
||||||
selectedSeriesId,
|
selectedSeriesId,
|
||||||
selectedLineStyle,
|
selectedLineStyle,
|
||||||
lineStyleOptions,
|
lineStyleOptions,
|
||||||
@@ -350,6 +352,7 @@ const chartModel = reactive({
|
|||||||
frameStyle,
|
frameStyle,
|
||||||
axes,
|
axes,
|
||||||
cleanView,
|
cleanView,
|
||||||
|
presentationMode,
|
||||||
showTooltip,
|
showTooltip,
|
||||||
zeroLine,
|
zeroLine,
|
||||||
frameWatermarkVisible,
|
frameWatermarkVisible,
|
||||||
|
|||||||
14
src/DemoRouterApp.vue
Normal file
14
src/DemoRouterApp.vue
Normal 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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ const props = withDefaults(defineProps<WaveformChartProps>(), {
|
|||||||
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>()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const {
|
|||||||
displayMode,
|
displayMode,
|
||||||
activeInteractionMode,
|
activeInteractionMode,
|
||||||
isCleanView,
|
isCleanView,
|
||||||
|
isPresentationMode,
|
||||||
selection,
|
selection,
|
||||||
containerStyle,
|
containerStyle,
|
||||||
overlayMode,
|
overlayMode,
|
||||||
@@ -45,7 +46,7 @@ const {
|
|||||||
beginSharedViewportDrag,
|
beginSharedViewportDrag,
|
||||||
finishViewportDrag,
|
finishViewportDrag,
|
||||||
cancelViewportDrag,
|
cancelViewportDrag,
|
||||||
clearHover,
|
handlePointerLeave,
|
||||||
handleAnnotationClick,
|
handleAnnotationClick,
|
||||||
handleAnnotationContextMenu,
|
handleAnnotationContextMenu,
|
||||||
resolveFrameNumber,
|
resolveFrameNumber,
|
||||||
@@ -107,12 +108,14 @@ const {
|
|||||||
`waveform-chart--interaction-${activeInteractionMode}`,
|
`waveform-chart--interaction-${activeInteractionMode}`,
|
||||||
{
|
{
|
||||||
'waveform-chart--clean': isCleanView,
|
'waveform-chart--clean': isCleanView,
|
||||||
|
'waveform-chart--presentation': isPresentationMode,
|
||||||
'waveform-chart--panning': selection?.mode === 'pan',
|
'waveform-chart--panning': selection?.mode === '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-title-area-height="titleAreaHeight"
|
:data-title-area-height="titleAreaHeight"
|
||||||
@@ -192,8 +195,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 +204,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 +217,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 +231,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,7 +240,7 @@ const {
|
|||||||
:state="hoverState"
|
:state="hoverState"
|
||||||
:tracks="trackLayouts"
|
:tracks="trackLayouts"
|
||||||
:clip-path-id="clipPathId"
|
:clip-path-id="clipPathId"
|
||||||
:visible="showTooltip"
|
:visible="showTooltip && !isPresentationMode"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<rect
|
<rect
|
||||||
@@ -253,6 +257,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"
|
||||||
@@ -318,7 +323,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 +335,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 +347,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"
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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') {
|
||||||
|
|||||||
116
src/components/core/fixedYDomainLayout.test.ts
Normal file
116
src/components/core/fixedYDomainLayout.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -4,6 +4,12 @@ import type { WaveformOverlayMode } from '../../types'
|
|||||||
import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../../utils'
|
import { formatScientificAxisExponent, 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, Y_AXIS_EXPONENT_GAP } 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, Y_AXIS_EXPONENT_GAP } from './constants'
|
||||||
@@ -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,12 +80,35 @@ export function buildYAxisSeriesGroups(
|
|||||||
return grouped
|
return grouped
|
||||||
}
|
}
|
||||||
|
|
||||||
export function axisTextMetrics(domain: [number, number]): {
|
export function resolveYAxisSeriesGroups(
|
||||||
|
track: DisplayTrack,
|
||||||
|
overlayMode: WaveformOverlayMode,
|
||||||
|
yDomain?: WaveformYDomain,
|
||||||
|
yDomains?: Record<string, WaveformYDomain>,
|
||||||
|
): YAxisSeriesGroup[] {
|
||||||
|
const trackDomain = resolveTrackFixedYDomain(track, yDomains)
|
||||||
|
return buildYAxisSeriesGroups(track, overlayMode).map((group) => {
|
||||||
|
if (trackDomain) return { ...group, domain: trackDomain, fixed: true }
|
||||||
|
const seriesDomains = group.seriesList.map(
|
||||||
|
(series) => resolveSeriesFixedYDomain(series, yDomain, yDomains) ?? series.yDomain,
|
||||||
|
)
|
||||||
|
const fixed = group.seriesList.some((series) =>
|
||||||
|
resolveSeriesFixedYDomain(series, yDomain, yDomains),
|
||||||
|
)
|
||||||
|
return fixed ? { ...group, domain: mergeYDomains(seriesDomains), fixed } : group
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function axisTextMetrics(
|
||||||
|
domain: [number, number],
|
||||||
|
nice = true,
|
||||||
|
): {
|
||||||
exponentLabel: string | null
|
exponentLabel: string | null
|
||||||
exponentWidth: number
|
exponentWidth: number
|
||||||
tickTextWidth: number
|
tickTextWidth: number
|
||||||
} {
|
} {
|
||||||
const scale = scaleLinear(domain, [1, 0]).nice()
|
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 = scale.ticks(10)
|
||||||
const maximumTickCharacters = Math.max(
|
const maximumTickCharacters = Math.max(
|
||||||
@@ -92,15 +123,15 @@ export function axisTextMetrics(domain: [number, number]): {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function axisExponentClearance(domain: [number, number]): number {
|
function axisExponentClearance(domain: [number, number], nice: boolean): number {
|
||||||
const { exponentLabel, exponentWidth } = axisTextMetrics(domain)
|
const { exponentLabel, exponentWidth } = axisTextMetrics(domain, nice)
|
||||||
return exponentLabel ? exponentWidth + Y_AXIS_EXPONENT_GAP : 0
|
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) +
|
axisExponentClearance(group.domain, !group.fixed) +
|
||||||
Y_AXIS_TICK_PADDING +
|
Y_AXIS_TICK_PADDING +
|
||||||
Y_AXIS_LABEL_GAP +
|
Y_AXIS_LABEL_GAP +
|
||||||
Y_AXIS_LABEL_BAND_WIDTH +
|
Y_AXIS_LABEL_BAND_WIDTH +
|
||||||
@@ -110,8 +141,8 @@ 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) +
|
axisExponentClearance(group.domain, !group.fixed) +
|
||||||
Y_AXIS_TICK_PADDING +
|
Y_AXIS_TICK_PADDING +
|
||||||
Y_AXIS_OUTER_PADDING
|
Y_AXIS_OUTER_PADDING
|
||||||
)
|
)
|
||||||
@@ -120,8 +151,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
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ import {
|
|||||||
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 { Y_AXIS_EXPONENT_GAP } from './constants'
|
||||||
import {
|
import {
|
||||||
@@ -42,6 +42,8 @@ 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'
|
||||||
rendering: ResolvedWaveformRenderingOptions
|
rendering: ResolvedWaveformRenderingOptions
|
||||||
@@ -94,13 +96,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,7 +117,10 @@ 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 { exponentLabel, exponentWidth, tickTextWidth } = axisTextMetrics(
|
||||||
|
group.domain,
|
||||||
|
!group.fixed,
|
||||||
|
)
|
||||||
const exponentClearance = exponentLabel ? exponentWidth + Y_AXIS_EXPONENT_GAP : 0
|
const exponentClearance = exponentLabel ? exponentWidth + Y_AXIS_EXPONENT_GAP : 0
|
||||||
const clearance =
|
const clearance =
|
||||||
tickTextWidth +
|
tickTextWidth +
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
shallowReactive,
|
shallowReactive,
|
||||||
shallowRef,
|
shallowRef,
|
||||||
toRefs,
|
toRefs,
|
||||||
|
watch,
|
||||||
watchEffect,
|
watchEffect,
|
||||||
type ComponentPublicInstance,
|
type ComponentPublicInstance,
|
||||||
type Ref,
|
type Ref,
|
||||||
@@ -68,6 +69,15 @@ export function useWaveformChartController(
|
|||||||
const annotationInteraction = useWaveformAnnotationInteraction()
|
const annotationInteraction = useWaveformAnnotationInteraction()
|
||||||
const editorSeriesOptions = ref<AnnotationSeriesCandidate[]>([])
|
const editorSeriesOptions = ref<AnnotationSeriesCandidate[]>([])
|
||||||
const hoverThrottle = useAnimationFrameThrottle()
|
const hoverThrottle = useAnimationFrameThrottle()
|
||||||
|
|
||||||
|
watch(
|
||||||
|
[() => props.yDomain, () => props.yDomains],
|
||||||
|
() => {
|
||||||
|
sharedYDomains.value = {}
|
||||||
|
independentYDomains.value = {}
|
||||||
|
},
|
||||||
|
{ deep: true },
|
||||||
|
)
|
||||||
const selection = ref<ViewportSelectionState | null>(null)
|
const selection = ref<ViewportSelectionState | null>(null)
|
||||||
const spacePressed = ref(false)
|
const spacePressed = ref(false)
|
||||||
const pointerInsideChart = ref(false)
|
const pointerInsideChart = ref(false)
|
||||||
@@ -90,6 +100,7 @@ export function useWaveformChartController(
|
|||||||
chartWidth,
|
chartWidth,
|
||||||
chartHeight,
|
chartHeight,
|
||||||
isCleanView,
|
isCleanView,
|
||||||
|
isPresentationMode,
|
||||||
hiddenSeriesIdSet,
|
hiddenSeriesIdSet,
|
||||||
resolvedTitleText,
|
resolvedTitleText,
|
||||||
titleAreaReserved,
|
titleAreaReserved,
|
||||||
@@ -151,6 +162,7 @@ export function useWaveformChartController(
|
|||||||
innerHeight,
|
innerHeight,
|
||||||
hasChartArea,
|
hasChartArea,
|
||||||
isZoomMode,
|
isZoomMode,
|
||||||
|
isPresentationMode,
|
||||||
resolveInitialTrackDomain,
|
resolveInitialTrackDomain,
|
||||||
cancelPendingHover: () => hover.cancelPendingHover(),
|
cancelPendingHover: () => hover.cancelPendingHover(),
|
||||||
})
|
})
|
||||||
@@ -174,6 +186,7 @@ export function useWaveformChartController(
|
|||||||
titleAreaHeight,
|
titleAreaHeight,
|
||||||
chartTopMargin,
|
chartTopMargin,
|
||||||
activeInteractionMode,
|
activeInteractionMode,
|
||||||
|
isPresentationMode,
|
||||||
})
|
})
|
||||||
|
|
||||||
const viewport = useWaveformViewport({
|
const viewport = useWaveformViewport({
|
||||||
@@ -192,6 +205,7 @@ export function useWaveformChartController(
|
|||||||
sharedYDomains,
|
sharedYDomains,
|
||||||
independentYDomains,
|
independentYDomains,
|
||||||
isZoomMode,
|
isZoomMode,
|
||||||
|
isPresentationMode,
|
||||||
editorSeriesOptions,
|
editorSeriesOptions,
|
||||||
resolveInitialTrackDomain,
|
resolveInitialTrackDomain,
|
||||||
canZoomTrack: zoom.canZoomTrack,
|
canZoomTrack: zoom.canZoomTrack,
|
||||||
@@ -214,6 +228,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 +263,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,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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'),
|
||||||
() => {
|
() => {
|
||||||
|
|||||||
@@ -26,7 +26,12 @@ import {
|
|||||||
resolveGridCellGeometry,
|
resolveGridCellGeometry,
|
||||||
X_AXIS_BAND,
|
X_AXIS_BAND,
|
||||||
} from './grid'
|
} from './grid'
|
||||||
import { buildTrackLayouts, measureTrackYAxisClearance, Y_AXIS_EXPONENT_GAP } from './layout'
|
import {
|
||||||
|
buildTrackLayouts,
|
||||||
|
measureTrackYAxisClearance,
|
||||||
|
resolveYAxisSeriesGroups,
|
||||||
|
Y_AXIS_EXPONENT_GAP,
|
||||||
|
} 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,8 +109,12 @@ 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),
|
||||||
|
)
|
||||||
|
.map((group) => {
|
||||||
|
const scale = scaleLinear(group.domain, [1, 0])
|
||||||
|
if (!group.fixed) scale.nice()
|
||||||
const [axisMin, axisMax] = scale.domain()
|
const [axisMin, axisMax] = scale.domain()
|
||||||
return {
|
return {
|
||||||
exponentLabel: formatScientificAxisExponent(axisMin, axisMax),
|
exponentLabel: formatScientificAxisExponent(axisMin, axisMax),
|
||||||
@@ -165,7 +174,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 +295,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(
|
||||||
|
|||||||
@@ -49,6 +49,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 {
|
||||||
@@ -178,6 +179,7 @@ export function useWaveformPresentation(context: PresentationContext) {
|
|||||||
chartHeight,
|
chartHeight,
|
||||||
containerStyle,
|
containerStyle,
|
||||||
isCleanView,
|
isCleanView,
|
||||||
|
isPresentationMode,
|
||||||
resolvedZeroLine,
|
resolvedZeroLine,
|
||||||
legendBackgroundColor,
|
legendBackgroundColor,
|
||||||
legendInteractive,
|
legendInteractive,
|
||||||
|
|||||||
@@ -31,6 +31,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
|
||||||
@@ -45,6 +47,7 @@ export interface WaveformChartProps {
|
|||||||
hiddenSeriesIds?: string[]
|
hiddenSeriesIds?: string[]
|
||||||
defaultHiddenSeriesIds?: string[]
|
defaultHiddenSeriesIds?: string[]
|
||||||
cleanView?: boolean
|
cleanView?: boolean
|
||||||
|
presentationMode?: boolean
|
||||||
zeroLine?: WaveformZeroLineOptions
|
zeroLine?: WaveformZeroLineOptions
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,6 +68,7 @@ type DefaultedProp =
|
|||||||
| 'legend'
|
| 'legend'
|
||||||
| 'defaultHiddenSeriesIds'
|
| 'defaultHiddenSeriesIds'
|
||||||
| 'cleanView'
|
| 'cleanView'
|
||||||
|
| 'presentationMode'
|
||||||
| 'zeroLine'
|
| 'zeroLine'
|
||||||
|
|
||||||
export type ResolvedWaveformChartProps = Readonly<
|
export type ResolvedWaveformChartProps = Readonly<
|
||||||
|
|||||||
56
src/components/core/yDomain.test.ts
Normal file
56
src/components/core/yDomain.test.ts
Normal 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])
|
||||||
|
})
|
||||||
|
})
|
||||||
50
src/components/core/yDomain.ts
Normal file
50
src/components/core/yDomain.ts
Normal 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])),
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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,6 +135,7 @@ export function useWaveformHover(context: HoverContext) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const handleSharedPointerMove = (event: PointerEvent) => {
|
const handleSharedPointerMove = (event: PointerEvent) => {
|
||||||
|
if (isPresentationMode.value) return
|
||||||
if (selection.value?.overlay === event.currentTarget) {
|
if (selection.value?.overlay === event.currentTarget) {
|
||||||
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,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { computed, nextTick, type ComputedRef, type Ref, type ShallowRef } from
|
|||||||
|
|
||||||
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,
|
||||||
@@ -26,6 +27,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
|
||||||
@@ -53,6 +55,7 @@ export function useWaveformViewport(context: ViewportContext) {
|
|||||||
sharedYDomains,
|
sharedYDomains,
|
||||||
independentYDomains,
|
independentYDomains,
|
||||||
isZoomMode,
|
isZoomMode,
|
||||||
|
isPresentationMode,
|
||||||
editorSeriesOptions,
|
editorSeriesOptions,
|
||||||
resolveInitialTrackDomain,
|
resolveInitialTrackDomain,
|
||||||
canZoomTrack,
|
canZoomTrack,
|
||||||
@@ -131,6 +134,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
|
||||||
@@ -187,9 +191,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,6 +211,7 @@ 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
|
if (!active || 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)
|
||||||
@@ -285,6 +294,10 @@ 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)
|
updateViewportDrag(event)
|
||||||
@@ -319,7 +332,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')
|
||||||
|
|||||||
@@ -29,6 +29,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 +51,7 @@ export function useWaveformZoom(context: ZoomContext) {
|
|||||||
innerHeight,
|
innerHeight,
|
||||||
hasChartArea,
|
hasChartArea,
|
||||||
isZoomMode,
|
isZoomMode,
|
||||||
|
isPresentationMode,
|
||||||
resolveInitialTrackDomain,
|
resolveInitialTrackDomain,
|
||||||
cancelPendingHover,
|
cancelPendingHover,
|
||||||
} = context
|
} = context
|
||||||
@@ -67,7 +69,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 +80,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 +88,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
|
||||||
@@ -233,7 +239,13 @@ export function useWaveformZoom(context: ZoomContext) {
|
|||||||
}
|
}
|
||||||
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') {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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' }),
|
||||||
})
|
})
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -294,6 +294,31 @@ describe('WaveformChart', () => {
|
|||||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
|
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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.00')
|
||||||
|
expect(yTickLabels()).toContain('97.00')
|
||||||
|
|
||||||
|
await wrapper.setProps({ yDomain: undefined })
|
||||||
|
await flushPromises()
|
||||||
|
expect(yTickLabels()).not.toContain('3.00')
|
||||||
|
expect(yTickLabels()).not.toContain('97.00')
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps annotations bound to their channel while paging', async () => {
|
it('keeps annotations bound to their channel while paging', async () => {
|
||||||
const wrapper = await mountSizedChart(gridSeries(3), {
|
const wrapper = await mountSizedChart(gridSeries(3), {
|
||||||
grid: { rowCount: 1, columnCount: 1 },
|
grid: { rowCount: 1, columnCount: 1 },
|
||||||
|
|||||||
306
src/components/waveformChartCases/presentationMode.test.ts
Normal file
306
src/components/waveformChartCases/presentationMode.test.ts
Normal 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)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -224,6 +224,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',
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ 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"
|
||||||
:zero-line="model.zeroLine"
|
:zero-line="model.zeroLine"
|
||||||
:frame-number="model.frameWatermarkVisible ? 1 : undefined"
|
:frame-number="model.frameWatermarkVisible ? 1 : undefined"
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export interface DemoControlPanelModel {
|
|||||||
overlayMode: WaveformOverlayMode
|
overlayMode: WaveformOverlayMode
|
||||||
showTooltip: boolean
|
showTooltip: boolean
|
||||||
cleanView: boolean
|
cleanView: boolean
|
||||||
|
presentationMode: boolean
|
||||||
selectedSeriesId: string
|
selectedSeriesId: string
|
||||||
selectedLineStyle: WaveformLineStyle
|
selectedLineStyle: WaveformLineStyle
|
||||||
lineStyleOptions: Array<SelectOption<WaveformLineStyle>>
|
lineStyleOptions: Array<SelectOption<WaveformLineStyle>>
|
||||||
@@ -87,6 +88,7 @@ export interface DemoChartModel {
|
|||||||
frameStyle: WaveformFrameStyle
|
frameStyle: WaveformFrameStyle
|
||||||
axes: WaveformAxesOptions
|
axes: WaveformAxesOptions
|
||||||
cleanView: boolean
|
cleanView: boolean
|
||||||
|
presentationMode: boolean
|
||||||
showTooltip: boolean
|
showTooltip: boolean
|
||||||
zeroLine: WaveformZeroLineOptions
|
zeroLine: WaveformZeroLineOptions
|
||||||
frameWatermarkVisible: boolean
|
frameWatermarkVisible: boolean
|
||||||
|
|||||||
197
src/demoRoutes.css
Normal file
197
src/demoRoutes.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
21
src/router.ts
Normal 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,
|
||||||
|
})
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
44
src/views/FixedYDomainDemo.test.ts
Normal file
44
src/views/FixedYDomainDemo.test.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
149
src/views/FixedYDomainDemo.vue
Normal file
149
src/views/FixedYDomainDemo.vue
Normal 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>
|
||||||
Reference in New Issue
Block a user