Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65a0c4933c |
198
README.md
198
README.md
@@ -1,66 +1,104 @@
|
|||||||
# Waveform Analysis
|
# Waveform Analysis
|
||||||
|
|
||||||
基于 Vue 3、TypeScript 和 D3 的响应式波形图组件库与示例项目。
|
基于 Vue 3、TypeScript 和 D3 的响应式 SVG 波形图组件。适合展示单通道、多通道和大规模采样
|
||||||
|
数据,内置缩放、tooltip、图例、误差棒、标注、分页和多 Y 轴叠加。
|
||||||
|
|
||||||
组件使用 SVG 绘制坐标轴和波形;大数据会按当前可见范围和屏幕像素自动保峰降采样,
|
组件使用不可变数据模型:替换 `data` 引用后会重新计算数据域和视口;大数据会按当前可见范围
|
||||||
tooltip 与标注仍使用完整原始数据。
|
和屏幕像素自动保峰降采样,而 tooltip、最近点查询和标注仍使用完整原始数据。
|
||||||
|
|
||||||
## 在线示例
|
## 在线示例
|
||||||
|
|
||||||
最新稳定版 Demo:<https://lqycustomsite.online/waveform-analysis/>
|
最新稳定版 Demo:<https://lqycustomsite.online/waveform-analysis/>
|
||||||
|
|
||||||
## 开始使用
|
## 特性
|
||||||
|
|
||||||
|
- Vue 3 Composition API + TypeScript,支持按需导入 `WaveformChart`
|
||||||
|
- 采样值、显式坐标点和多系列数据模型
|
||||||
|
- `independent`、`separated`、`compact` 三种布局模式
|
||||||
|
- 曲线、阶梯线、点符号和对称/非对称误差棒
|
||||||
|
- 缩放过程事件、缩放结束按可视区间加载和视口重置
|
||||||
|
- 多系列图例、受控显隐、网格分页和最多四根 Y 轴
|
||||||
|
- 受控标注、右键编辑、拖拽避让和自定义颜色
|
||||||
|
- 标题、图框、坐标轴、时间单位和降采样参数可配置
|
||||||
|
|
||||||
|
## 安装
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install
|
pnpm install
|
||||||
pnpm dev
|
pnpm dev
|
||||||
```
|
```
|
||||||
|
|
||||||
开发环境要求 Node.js 22、pnpm,以及支持 Vue 3 的宿主项目。组件库会将 Vue、D3、
|
组件库将 Vue、D3、Ant Design Vue 和 vue3-colorpicker 作为 peer dependency;直接安装到业务项目时请一并
|
||||||
Ant Design Vue 和 vue3-colorpicker 作为 peer dependency;直接安装到业务项目时请一并
|
|
||||||
安装这些依赖:
|
安装这些依赖:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm add waveform-analysis vue d3 ant-design-vue vue3-colorpicker
|
pnpm add waveform-analysis vue d3 ant-design-vue vue3-colorpicker
|
||||||
```
|
```
|
||||||
|
|
||||||
## 常用命令
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pnpm typecheck
|
|
||||||
pnpm lint
|
|
||||||
pnpm test
|
|
||||||
pnpm test:coverage
|
|
||||||
pnpm build
|
|
||||||
```
|
|
||||||
|
|
||||||
`pnpm build` 同时生成 `dist` 组件库产物和 `dist-demo` 演示应用。发布稳定版后,在线示例
|
|
||||||
会自动更新;预发布版本不会覆盖在线示例。正式公开入口为
|
|
||||||
`src/index.ts`;发布后使用包入口:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
import { WaveformChart, type WaveformData } from 'waveform-analysis'
|
|
||||||
import 'waveform-analysis/style.css'
|
|
||||||
```
|
|
||||||
|
|
||||||
Vue、D3、Ant Design Vue 和 vue3-colorpicker 是 peer dependencies,需要由使用方安装。
|
|
||||||
`WaveformChart` 支持采样值与采样率,也支持显式的 `{ x, y }[]` 点数组。
|
|
||||||
|
|
||||||
## 发布
|
## 发布
|
||||||
|
|
||||||
发布由推送版本 tag 触发。先将 `package.json` 的 `version` 更新为目标版本并提交,再创建同版本 tag:
|
发布由推送版本 tag 触发。`package.json` 的 `version` 必须与 tag 去掉 `v` 后完全一致。
|
||||||
|
稳定版使用 `vX.Y.Z`,预发布版使用 `vX.Y.Z-rc.1`;稳定版发布为 npm `latest`,预发布版发布为
|
||||||
|
`next`。流水线会创建 Gitea Release,并上传包文件与 SHA-256 校验文件。
|
||||||
|
|
||||||
```bash
|
## 最小示例
|
||||||
git tag -a v0.1.7 -m "Release v0.1.7"
|
|
||||||
git push origin main --follow-tags
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { WaveformChart, type WaveformData } from 'waveform-analysis'
|
||||||
|
import 'waveform-analysis/style.css'
|
||||||
|
|
||||||
|
const data = ref<WaveformData>({
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0.2 },
|
||||||
|
{ x: 0.001, y: 0.4 },
|
||||||
|
{ x: 0.002, y: 0.1 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="chart-container">
|
||||||
|
<WaveformChart :data="data" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.chart-container {
|
||||||
|
height: 420px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
```
|
```
|
||||||
|
|
||||||
支持稳定版 `vX.Y.Z` 与预发布版 `vX.Y.Z-rc.1`。tag 去掉 `v` 后必须与 `package.json` 的
|
父容器需要有明确高度;未指定 `width` 或 `height` 时,组件会填充父容器,并保持最小高度
|
||||||
`version` 完全一致。稳定版发布为 npm `latest` 并更新服务器下载目录的 latest 链接;预发布版发布为
|
`180px`。`WaveformChart` 的正式入口为 `src/index.ts`,样式入口为 `waveform-analysis/style.css`。
|
||||||
npm `next`,不会覆盖稳定版 latest。流水线会创建 Gitea Release,并上传 `.tgz` 与 SHA-256 校验文件。
|
|
||||||
|
|
||||||
仓库 Actions 需要配置 `NPM_PUBLISH_TOKEN`(npm 包发布权限)和 `RELEASE_TOKEN`(仓库 Release
|
## API 速查
|
||||||
写入权限)两个 Secret。
|
|
||||||
|
### Props
|
||||||
|
|
||||||
|
| Prop | 类型 | 默认值 | 说明 |
|
||||||
|
| --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | --------------------------------- |
|
||||||
|
| `data` | `WaveformData` | 必填 | 波形数据 |
|
||||||
|
| `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 |
|
||||||
|
| `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 |
|
||||||
|
| `timeUnit` | `'s' \| 'ms'` | `'ms'` | 坐标轴和 tooltip 展示单位 |
|
||||||
|
| `width` / `height` | `number` | 自适应 | 组件总尺寸,单位为 CSS 像素 |
|
||||||
|
| `zoomable` / `showTooltip` | `boolean` | `true` / `true` | 缩放和 tooltip 开关 |
|
||||||
|
| `minZoomSpan` | `number` | 未设置 | 最小缩放跨度,使用原始 X 数据单位 |
|
||||||
|
| `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围 |
|
||||||
|
| `initialXDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置初始范围 |
|
||||||
|
| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 |
|
||||||
|
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
|
||||||
|
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
|
||||||
|
| `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 |
|
||||||
|
| `hiddenSeriesIds` | `string[]` | 未设置 | 受控隐藏系列 ID |
|
||||||
|
| `defaultHiddenSeriesIds` | `string[]` | `[]` | 非受控模式的初始隐藏系列 |
|
||||||
|
|
||||||
|
所有公开类型均可从包入口导入,例如 `WaveformData`、`WaveformSeries`、
|
||||||
|
`WaveformAnnotation`、`WaveformRenderingOptions` 和 `WaveformGridOptions`。
|
||||||
|
|
||||||
### 数据结构
|
### 数据结构
|
||||||
|
|
||||||
@@ -114,21 +152,42 @@ import { WaveformChart } from './index'
|
|||||||
|
|
||||||
### 缩放后按可视区间加载数据
|
### 缩放后按可视区间加载数据
|
||||||
|
|
||||||
组件会在一次缩放手势结束后触发 `zoom-end`,调用方可以使用端点请求后端,再通过 `data`
|
组件支持 Plotly 风格的矩形框选缩放:在 zoom 模式下按住鼠标左键拖拽,松开后同时缩放
|
||||||
传回新数据。共享 X 轴模式的 payload 为 `{ start, end }`;独立分图模式还会包含
|
X/Y 轴;按住空格键拖拽可平移当前视口。鼠标滚轮仍可放大,双击恢复完整视口。
|
||||||
`trackIndex` 和稳定的 `seriesIds`。
|
组件会在滚轮或框选缩放结束后触发 `zoom-end`,调用方可以使用端点请求后端,再通过
|
||||||
|
`data` 传回新数据。独立分图模式还会包含 `trackIndex` 和稳定的 `seriesIds`。
|
||||||
|
|
||||||
```vue
|
```vue
|
||||||
<WaveformChart :data="chartData" @zoom-end="loadVisibleData" />
|
<WaveformChart
|
||||||
|
ref="chart"
|
||||||
|
:data="chartData"
|
||||||
|
:initial-x-domain="initialDomain"
|
||||||
|
:min-zoom-span="initialDomainSpan / 40"
|
||||||
|
@zoom-end="loadVisibleData"
|
||||||
|
@zoom-reset="restoreInitialData"
|
||||||
|
/>
|
||||||
```
|
```
|
||||||
|
|
||||||
`zoom-change` 仍会在缩放过程中持续触发,适合更新外部状态;后端请求应使用
|
`zoom-change` 会在滚轮、框选和平移过程中触发,适合更新外部状态;后端请求应使用
|
||||||
`zoom-end` 或在 `zoom-change` 上自行防抖。标注数据应由父组件独立持有,替换波形数据时
|
`zoom-end`,或在 `zoom-change` 上自行防抖。标注数据应由父组件独立持有,替换波形数据时
|
||||||
不要清空标注,组件会根据当前数据域自动隐藏或恢复对应标注。
|
不要清空标注,组件会根据当前数据域自动隐藏或恢复对应标注。
|
||||||
|
|
||||||
|
`zoom-end.gesture` 用于区分 `wheel` 和 `box`。单轨道 payload 使用 `yStart/yEnd`;共享
|
||||||
|
X 轴且包含多个轨道时使用按稳定 track ID 索引的 `yRanges`。平移不会触发 `zoom-end`,
|
||||||
|
因此不会自动发起新的区间加载请求。
|
||||||
|
|
||||||
调用方应处理加载失败的情况(网络错误、超时等),并保持旧数据或显示加载状态。生产环境建议使用
|
调用方应处理加载失败的情况(网络错误、超时等),并保持旧数据或显示加载状态。生产环境建议使用
|
||||||
`AbortController` 取消过时的请求。
|
`AbortController` 取消过时的请求。
|
||||||
|
|
||||||
|
`initialXDomain` 固定首次完整数据的 X 轴缩放边界,不要将它改成后端返回的当前窗口;独立图框有不同时间范围时,可通过
|
||||||
|
`initialXDomains` 按 track ID 或 series ID 分别配置。`minZoomSpan` 使用原始 X 数据单位,
|
||||||
|
可防止每次区间数据回填后重新累计放大。双击图框会
|
||||||
|
重置组件内部缩放并触发 `zoom-reset`;调用方应在事件中取消区间请求并恢复首次完整数据。
|
||||||
|
外部重置按钮也可以通过模板引用调用组件公开的 `resetViewport()` 方法,然后执行相同的数据恢复逻辑。
|
||||||
|
|
||||||
|
独立坐标模式下,回填响应应只替换 `seriesIds` 对应的系列,并调用
|
||||||
|
`resetViewport(trackIndex)`;其他图框的数据和缩放状态应保持不变。
|
||||||
|
|
||||||
多通道数据应为每个 `WaveformSeries` 提供稳定的 `id`。内部时间坐标始终使用秒,
|
多通道数据应为每个 `WaveformSeries` 提供稳定的 `id`。内部时间坐标始终使用秒,
|
||||||
`timeUnit` 只控制坐标轴和 tooltip 的显示单位。
|
`timeUnit` 只控制坐标轴和 tooltip 的显示单位。
|
||||||
|
|
||||||
@@ -395,14 +454,15 @@ X、Y 轴会根据各自完整显示域选择格式:最大绝对值在 `[0.01,
|
|||||||
|
|
||||||
组件提供以下事件,名称与 Vue 模板写法一致:
|
组件提供以下事件,名称与 Vue 模板写法一致:
|
||||||
|
|
||||||
| 事件 | 说明 |
|
| 事件 | 说明 |
|
||||||
| --------------------------------------------------------------- | ---------------------------------------------------------- |
|
| --------------------------------------------------------------- | -------------------------------------------------------------- |
|
||||||
| `point-hover` | 当前最近点变化时触发,离开图表时传入 `null` |
|
| `point-hover` | 当前最近点变化时触发,离开图表时传入 `null` |
|
||||||
| `zoom-change` | 缩放过程中触发,参数为 `[start, end]` |
|
| `zoom-change` | 缩放过程中触发,参数为 `[start, end]` |
|
||||||
| `zoom-end` | 缩放结束后触发;独立分图模式附带 `trackIndex`、`seriesIds` |
|
| `zoom-end` | 滚轮放大结束后触发;独立分图模式附带 `trackIndex`、`seriesIds` |
|
||||||
| `page-change` | 分页变化,参数为当前页和总页数 |
|
| `zoom-reset` | 双击重置视口时触发,调用方应恢复首次完整数据 |
|
||||||
| `series-visibility-change` | 图例切换曲线显隐时触发 |
|
| `page-change` | 分页变化,参数为当前页和总页数 |
|
||||||
| `annotation-create` / `annotation-update` / `annotation-delete` | 标注新增、更新或删除 |
|
| `series-visibility-change` | 图例切换曲线显隐时触发 |
|
||||||
|
| `annotation-create` / `annotation-update` / `annotation-delete` | 标注新增、更新或删除 |
|
||||||
|
|
||||||
`annotations`、`annotations-visible`、`interaction-mode` 和 `hidden-series-ids` 均支持
|
`annotations`、`annotations-visible`、`interaction-mode` 和 `hidden-series-ids` 均支持
|
||||||
`v-model`;业务层应负责将标注和显隐状态持久化。
|
`v-model`;业务层应负责将标注和显隐状态持久化。
|
||||||
@@ -414,5 +474,37 @@ X、Y 轴会根据各自完整显示域选择格式:最大绝对值在 `[0.01,
|
|||||||
- `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` 中提供示例波形数据
|
||||||
|
|
||||||
构建后,`dist/` 是可发布的组件库,`dist-demo/` 是 demo 静态产物;两者均为生成目录,
|
## 本地开发
|
||||||
不要手工编辑。
|
|
||||||
|
开发环境要求 Node.js 22 和 pnpm:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm install
|
||||||
|
pnpm dev
|
||||||
|
```
|
||||||
|
|
||||||
|
常用质量检查和构建命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm typecheck
|
||||||
|
pnpm lint
|
||||||
|
pnpm test
|
||||||
|
pnpm test:coverage
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
`pnpm build` 同时生成 `dist/` 组件库产物和 `dist-demo/` 演示应用。正式公开入口为
|
||||||
|
`src/index.ts`,样式入口为 `src/styles.css`;`dist/` 和 `dist-demo/` 均为生成目录,不要手工编辑。
|
||||||
|
|
||||||
|
## 发布流程
|
||||||
|
|
||||||
|
发布由推送版本 tag 触发。先将 `package.json` 的 `version` 更新为目标版本并提交,再创建同版本 tag:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git tag -a v0.1.7 -m "Release v0.1.7"
|
||||||
|
git push origin main --follow-tags
|
||||||
|
```
|
||||||
|
|
||||||
|
支持稳定版 `vX.Y.Z` 与预发布版 `vX.Y.Z-rc.1`。tag 去掉 `v` 后必须与 `package.json` 的
|
||||||
|
`version` 完全一致。稳定版发布为 npm `latest`,预发布版发布为 npm `next`。流水线会创建 Gitea
|
||||||
|
Release,并上传 `.tgz` 与 SHA-256 校验文件。
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "waveform-analysis",
|
"name": "waveform-analysis",
|
||||||
"version": "0.1.11",
|
"version": "0.1.12",
|
||||||
"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",
|
||||||
|
|||||||
@@ -1,11 +1,36 @@
|
|||||||
import { flushPromises, mount } from '@vue/test-utils'
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
import { InputNumber, Select } from 'ant-design-vue'
|
import { InputNumber, Select } from 'ant-design-vue'
|
||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
import { ColorPicker } from 'vue3-colorpicker'
|
import { ColorPicker } from 'vue3-colorpicker'
|
||||||
|
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
|
import { WaveformChart, type WaveformData } from './components'
|
||||||
|
|
||||||
describe('App workspace layout', { timeout: 20_000 }, () => {
|
describe('App workspace layout', { timeout: 20_000 }, () => {
|
||||||
|
it('restores full data and invalidates a pending zoom request', async () => {
|
||||||
|
vi.useFakeTimers()
|
||||||
|
const wrapper = mount(App)
|
||||||
|
try {
|
||||||
|
await flushPromises()
|
||||||
|
const chart = wrapper.getComponent(WaveformChart)
|
||||||
|
const pointCount = (data: WaveformData) =>
|
||||||
|
data.kind === 'series' && data.series[0]?.data.kind === 'points'
|
||||||
|
? data.series[0].data.points.length
|
||||||
|
: 0
|
||||||
|
const initialPointCount = pointCount(chart.props('data') as WaveformData)
|
||||||
|
|
||||||
|
chart.vm.$emit('zoom-end', { start: 0, end: 0.001 })
|
||||||
|
await wrapper.get('[aria-label="重置波形视图"]').trigger('click')
|
||||||
|
await vi.advanceTimersByTimeAsync(100)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(pointCount(chart.props('data') as WaveformData)).toBe(initialPointCount)
|
||||||
|
} finally {
|
||||||
|
wrapper.unmount()
|
||||||
|
vi.useRealTimers()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
it('places controls in the sidebar beside the chart', async () => {
|
it('places controls in the sidebar beside the chart', async () => {
|
||||||
const wrapper = mount(App)
|
const wrapper = mount(App)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
|
|||||||
58
src/App.vue
58
src/App.vue
@@ -20,6 +20,7 @@ import {
|
|||||||
} from './components'
|
} from './components'
|
||||||
import chartWaveformsJson from './data/chartWaveforms.json'
|
import chartWaveformsJson from './data/chartWaveforms.json'
|
||||||
import demoWaveformsJson from './data/demoWaveforms.json'
|
import demoWaveformsJson from './data/demoWaveforms.json'
|
||||||
|
import { normalizeWaveformSeries } from './core'
|
||||||
|
|
||||||
interface WaveformSourcePoint {
|
interface WaveformSourcePoint {
|
||||||
x: number
|
x: number
|
||||||
@@ -187,7 +188,25 @@ const fullChartData: WaveformData = {
|
|||||||
kind: 'series',
|
kind: 'series',
|
||||||
series: [...frameOneSeries, ...basicCurveDemoSeries, ...stepDemoSeries, ...remainingSeries],
|
series: [...frameOneSeries, ...basicCurveDemoSeries, ...stepDemoSeries, ...remainingSeries],
|
||||||
}
|
}
|
||||||
|
const initialXValues = normalizeWaveformSeries(fullChartData).flatMap((series) =>
|
||||||
|
series.points.map((point) => point.x),
|
||||||
|
)
|
||||||
|
const [initialXMinimum, initialXMaximum] = initialXValues.reduce<[number, number]>(
|
||||||
|
([minimum, maximum], value) => [Math.min(minimum, value), Math.max(maximum, value)],
|
||||||
|
[Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY],
|
||||||
|
)
|
||||||
|
const initialXSpan = initialXMaximum - initialXMinimum
|
||||||
|
const minZoomSpan =
|
||||||
|
Number.isFinite(initialXSpan) && initialXSpan > 0 ? initialXSpan / 40 : undefined
|
||||||
|
const initialXDomainValue: [number, number] | undefined =
|
||||||
|
Number.isFinite(initialXMinimum) && Number.isFinite(initialXMaximum)
|
||||||
|
? [initialXMinimum, initialXMaximum]
|
||||||
|
: undefined
|
||||||
|
// Keep the full source domain stable while viewport data windows are replaced.
|
||||||
|
const initialXDomain = ref<[number, number] | undefined>(initialXDomainValue)
|
||||||
const chartData = ref<WaveformData>(fullChartData)
|
const chartData = ref<WaveformData>(fullChartData)
|
||||||
|
const waveformChartRef = ref<{ resetViewport: (trackIndex?: number) => void }>()
|
||||||
|
|
||||||
let zoomRequestSequence = 0
|
let zoomRequestSequence = 0
|
||||||
|
|
||||||
function filterWaveformData(data: WaveformData, start: number, end: number): WaveformData {
|
function filterWaveformData(data: WaveformData, start: number, end: number): WaveformData {
|
||||||
@@ -215,6 +234,22 @@ function filterWaveformData(data: WaveformData, start: number, end: number): Wav
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function mergeIndependentWindow(
|
||||||
|
currentData: WaveformData,
|
||||||
|
responseData: WaveformData,
|
||||||
|
seriesIds: string[],
|
||||||
|
): WaveformData {
|
||||||
|
if (currentData.kind !== 'series' || responseData.kind !== 'series') return responseData
|
||||||
|
const responseById = new Map(responseData.series.map((series) => [series.id, series]))
|
||||||
|
const changedIds = new Set(seriesIds)
|
||||||
|
return {
|
||||||
|
kind: 'series',
|
||||||
|
series: currentData.series.map((series) =>
|
||||||
|
series.id && changedIds.has(series.id) ? (responseById.get(series.id) ?? series) : series,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleZoomEnd(payload: WaveformZoomEndPayload) {
|
async function handleZoomEnd(payload: WaveformZoomEndPayload) {
|
||||||
// Demo-only sequence number cancellation. Production code should use AbortController
|
// Demo-only sequence number cancellation. Production code should use AbortController
|
||||||
// to cancel in-flight requests when a newer zoom gesture arrives.
|
// to cancel in-flight requests when a newer zoom gesture arrives.
|
||||||
@@ -224,7 +259,18 @@ async function handleZoomEnd(payload: WaveformZoomEndPayload) {
|
|||||||
|
|
||||||
// Demo-only stand-in for the backend response. Production code should replace this
|
// Demo-only stand-in for the backend response. Production code should replace this
|
||||||
// with a request using payload.start/payload.end and the optional channel metadata.
|
// with a request using payload.start/payload.end and the optional channel metadata.
|
||||||
chartData.value = filterWaveformData(fullChartData, payload.start, payload.end)
|
const responseData = filterWaveformData(fullChartData, payload.start, payload.end)
|
||||||
|
chartData.value =
|
||||||
|
payload.trackIndex !== undefined && payload.seriesIds?.length
|
||||||
|
? mergeIndependentWindow(chartData.value, responseData, payload.seriesIds)
|
||||||
|
: responseData
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetWaveformViewport() {
|
||||||
|
zoomRequestSequence += 1
|
||||||
|
chartData.value = fullChartData
|
||||||
|
initialXDomain.value = initialXDomainValue
|
||||||
|
waveformChartRef.value?.resetViewport()
|
||||||
}
|
}
|
||||||
const titleOptions = computed<WaveformTitleOptions>(() => ({
|
const titleOptions = computed<WaveformTitleOptions>(() => ({
|
||||||
visible: titleVisible.value,
|
visible: titleVisible.value,
|
||||||
@@ -304,6 +350,11 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleWindowKeydown)
|
|||||||
</Radio.Group>
|
</Radio.Group>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="control-section">
|
||||||
|
<h2>视图</h2>
|
||||||
|
<Button block aria-label="重置波形视图" @click="resetWaveformViewport">重置视图</Button>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="control-section">
|
<section class="control-section">
|
||||||
<h2>叠加方式</h2>
|
<h2>叠加方式</h2>
|
||||||
<Radio.Group
|
<Radio.Group
|
||||||
@@ -535,7 +586,11 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleWindowKeydown)
|
|||||||
|
|
||||||
<section class="chart-panel">
|
<section class="chart-panel">
|
||||||
<WaveformChart
|
<WaveformChart
|
||||||
|
ref="waveformChartRef"
|
||||||
:data="chartData"
|
:data="chartData"
|
||||||
|
:min-zoom-span="minZoomSpan"
|
||||||
|
:min-visible-points="5"
|
||||||
|
:initial-x-domain="initialXDomain"
|
||||||
:display-mode="displayMode"
|
:display-mode="displayMode"
|
||||||
:overlay-mode="overlayMode"
|
:overlay-mode="overlayMode"
|
||||||
:grid="{ rowCount, columnCount, showPagination: true }"
|
:grid="{ rowCount, columnCount, showPagination: true }"
|
||||||
@@ -553,6 +608,7 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleWindowKeydown)
|
|||||||
v-model:interaction-mode="interactionMode"
|
v-model:interaction-mode="interactionMode"
|
||||||
v-model:hidden-series-ids="hiddenSeriesIds"
|
v-model:hidden-series-ids="hiddenSeriesIds"
|
||||||
@zoom-end="handleZoomEnd"
|
@zoom-end="handleZoomEnd"
|
||||||
|
@zoom-reset="resetWaveformViewport"
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -1261,6 +1261,75 @@ describe('WaveformChart', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('uses each track x domain in independent mode instead of the shared initial domain', async () => {
|
||||||
|
const wrapper = await mountSizedChart(
|
||||||
|
{
|
||||||
|
kind: 'series',
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
id: 'narrow-track',
|
||||||
|
name: '窄范围',
|
||||||
|
data: {
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 0.006, y: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'wide-track',
|
||||||
|
name: '宽范围',
|
||||||
|
data: {
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: -8, y: 0 },
|
||||||
|
{ x: 5, y: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayMode: 'independent',
|
||||||
|
grid: { rowCount: 1, columnCount: 2 },
|
||||||
|
initialXDomain: [-8, 5],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const tracks = wrapper.findAll('.waveform-chart__track')
|
||||||
|
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('6.00')
|
||||||
|
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5.00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses an explicit initial x domain override for an independent track', async () => {
|
||||||
|
const wrapper = await mountSizedChart(
|
||||||
|
{
|
||||||
|
kind: 'series',
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
id: 'narrow-track',
|
||||||
|
name: '窄范围',
|
||||||
|
data: {
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 0.006, y: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
displayMode: 'independent',
|
||||||
|
initialXDomain: [-8, 5],
|
||||||
|
initialXDomains: { 'narrow-track': [0, 1] },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.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 },
|
||||||
@@ -1838,6 +1907,9 @@ describe('WaveformChart', () => {
|
|||||||
).toBeUndefined()
|
).toBeUndefined()
|
||||||
expect(wrapper.get('.waveform-chart__plot-background').attributes('fill')).toBe('transparent')
|
expect(wrapper.get('.waveform-chart__plot-background').attributes('fill')).toBe('transparent')
|
||||||
expect(wrapper.get('.waveform-chart__watermark').text()).toBe('12')
|
expect(wrapper.get('.waveform-chart__watermark').text()).toBe('12')
|
||||||
|
expect(getComputedStyle(wrapper.get('.waveform-chart__watermark').element).userSelect).toBe(
|
||||||
|
'none',
|
||||||
|
)
|
||||||
expect(wrapper.get('.waveform-chart__line').attributes('stroke')).toBe('#0960bd')
|
expect(wrapper.get('.waveform-chart__line').attributes('stroke')).toBe('#0960bd')
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -2122,11 +2194,207 @@ describe('WaveformChart', () => {
|
|||||||
expect(payload.start).toBeGreaterThanOrEqual(0)
|
expect(payload.start).toBeGreaterThanOrEqual(0)
|
||||||
expect(payload.end).toBeLessThanOrEqual(2)
|
expect(payload.end).toBeLessThanOrEqual(2)
|
||||||
expect(payload.start).toBeLessThan(payload.end)
|
expect(payload.start).toBeLessThan(payload.end)
|
||||||
|
expect(payload.end - payload.start).toBeCloseTo(2 / 40)
|
||||||
} finally {
|
} finally {
|
||||||
vi.useRealTimers()
|
vi.useRealTimers()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('zooms only the x axis and identifies the box gesture', async () => {
|
||||||
|
const wrapper = await mountSizedChart({
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 10 },
|
||||||
|
{ x: 2, y: 20 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const overlay = wrapper.get('.waveform-chart__overlay--independent')
|
||||||
|
const width = Number(overlay.attributes('width'))
|
||||||
|
const height = Number(overlay.attributes('height'))
|
||||||
|
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||||
|
value: () => ({ left: 0, top: 0, width, height }),
|
||||||
|
})
|
||||||
|
|
||||||
|
const dispatchPointer = (type: string, clientX: number, clientY: number) => {
|
||||||
|
const event = new MouseEvent(type, { button: 0, clientX, clientY, bubbles: true })
|
||||||
|
Object.defineProperty(event, 'pointerId', { value: 7 })
|
||||||
|
overlay.element.dispatchEvent(event)
|
||||||
|
}
|
||||||
|
dispatchPointer('pointerdown', width * 0.25, height / 2)
|
||||||
|
dispatchPointer('pointermove', width * 0.75, height / 2 + 2)
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(true)
|
||||||
|
dispatchPointer('pointerup', width * 0.75, height * 0.75)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const payload = wrapper.emitted('zoom-end')?.at(-1)?.[0] as
|
||||||
|
| {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
yStart?: number
|
||||||
|
yEnd?: number
|
||||||
|
trackIndex: number
|
||||||
|
seriesIds: string[]
|
||||||
|
gesture: string
|
||||||
|
}
|
||||||
|
| undefined
|
||||||
|
expect(payload).toMatchObject({ trackIndex: 0, seriesIds: ['series-0'], gesture: 'box' })
|
||||||
|
expect(payload?.end).toBeGreaterThan(payload?.start ?? Number.POSITIVE_INFINITY)
|
||||||
|
expect(payload?.yStart).toBeDefined()
|
||||||
|
expect(payload?.yEnd).toBeDefined()
|
||||||
|
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('limits box zoom to the configured minimum x span', async () => {
|
||||||
|
const wrapper = await mountSizedChart(
|
||||||
|
{
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 10 },
|
||||||
|
{ x: 2, y: 20 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ minZoomSpan: 0.25 },
|
||||||
|
)
|
||||||
|
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 dispatchPointer = (type: string, clientX: number, clientY: number) => {
|
||||||
|
const event = new MouseEvent(type, { button: 0, clientX, clientY, bubbles: true })
|
||||||
|
Object.defineProperty(event, 'pointerId', { value: 8 })
|
||||||
|
overlay.element.dispatchEvent(event)
|
||||||
|
}
|
||||||
|
dispatchPointer('pointerdown', width / 2, height / 2)
|
||||||
|
dispatchPointer('pointermove', width / 2 + 8, height / 2 + 8)
|
||||||
|
dispatchPointer('pointerup', width / 2 + 8, height / 2 + 8)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const payload = wrapper.emitted('zoom-end')?.at(-1)?.[0] as { start: number; end: number }
|
||||||
|
expect(payload.end - payload.start).toBeGreaterThanOrEqual(0.25 - 1e-8)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not zoom a track when its visible sample count is below the configured minimum', async () => {
|
||||||
|
const wrapper = await mountSizedChart(
|
||||||
|
{
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 10 },
|
||||||
|
{ x: 2, y: 20 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ minVisiblePoints: 10 },
|
||||||
|
)
|
||||||
|
const overlay = wrapper.get('.waveform-chart__overlay--independent')
|
||||||
|
const width = Number(overlay.attributes('width'))
|
||||||
|
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||||
|
value: () => ({ left: 0, top: 0, width, height: 290 }),
|
||||||
|
})
|
||||||
|
overlay.element.dispatchEvent(
|
||||||
|
new WheelEvent('wheel', {
|
||||||
|
deltaY: -1000,
|
||||||
|
clientX: width / 2,
|
||||||
|
clientY: 145,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
flushAnimationFrames()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.emitted('zoom-change')).toBeUndefined()
|
||||||
|
expect(wrapper.emitted('zoom-end')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores shared viewport dragging', async () => {
|
||||||
|
const wrapper = await mountSizedChart({
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 2, y: 1 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const overlay = wrapper.get('.waveform-chart__overlay')
|
||||||
|
const width = Number(overlay.attributes('width'))
|
||||||
|
const createDragEvent = (type: string, init: MouseEventInit) => {
|
||||||
|
const event = new MouseEvent(type, init)
|
||||||
|
Object.defineProperty(event, 'view', { value: window })
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||||
|
value: () => ({ left: 0, top: 0, width, height: 290 }),
|
||||||
|
})
|
||||||
|
overlay.element.dispatchEvent(
|
||||||
|
createDragEvent('mousedown', {
|
||||||
|
button: 0,
|
||||||
|
clientX: width / 2,
|
||||||
|
clientY: 145,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MouseEvent('mousemove', {
|
||||||
|
clientX: width / 2 + 80,
|
||||||
|
clientY: 145,
|
||||||
|
bubbles: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
window.dispatchEvent(createDragEvent('mouseup', { bubbles: true }))
|
||||||
|
flushAnimationFrames()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.emitted('zoom-change')).toBeUndefined()
|
||||||
|
expect(wrapper.emitted('zoom-end')).toBeUndefined()
|
||||||
|
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0.00')
|
||||||
|
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('ignores independent viewport dragging', async () => {
|
||||||
|
const wrapper = await mountSizedChart(gridSeries(2), {
|
||||||
|
displayMode: 'independent',
|
||||||
|
grid: { rowCount: 1, columnCount: 2 },
|
||||||
|
})
|
||||||
|
const overlay = wrapper.findAll('.waveform-chart__overlay--independent')[0]
|
||||||
|
const width = Number(overlay.attributes('width'))
|
||||||
|
const createDragEvent = (type: string, init: MouseEventInit) => {
|
||||||
|
const event = new MouseEvent(type, init)
|
||||||
|
Object.defineProperty(event, 'view', { value: window })
|
||||||
|
return event
|
||||||
|
}
|
||||||
|
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||||
|
value: () => ({ left: 0, top: 0, width, height: 260 }),
|
||||||
|
})
|
||||||
|
|
||||||
|
overlay.element.dispatchEvent(
|
||||||
|
createDragEvent('mousedown', {
|
||||||
|
button: 0,
|
||||||
|
clientX: width / 2,
|
||||||
|
clientY: 130,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
window.dispatchEvent(
|
||||||
|
new MouseEvent('mousemove', {
|
||||||
|
clientX: width / 2 + 60,
|
||||||
|
clientY: 130,
|
||||||
|
bubbles: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
window.dispatchEvent(createDragEvent('mouseup', { bubbles: true }))
|
||||||
|
flushAnimationFrames()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.emitted('zoom-change')).toBeUndefined()
|
||||||
|
expect(wrapper.emitted('zoom-end')).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('includes track and series IDs in independent zoom-end payloads', async () => {
|
it('includes track and series IDs in independent zoom-end payloads', async () => {
|
||||||
vi.useFakeTimers()
|
vi.useFakeTimers()
|
||||||
try {
|
try {
|
||||||
@@ -2161,6 +2429,222 @@ describe('WaveformChart', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('keeps the global minimum zoom span after replacing the data window', async () => {
|
||||||
|
const wrapper = await mountSizedChart(
|
||||||
|
{
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 100, y: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ minZoomSpan: 10 },
|
||||||
|
)
|
||||||
|
const overlay = wrapper.get('.waveform-chart__overlay')
|
||||||
|
const width = Number(overlay.attributes('width'))
|
||||||
|
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||||
|
value: () => ({ left: 0, top: 0, width, height: 290 }),
|
||||||
|
})
|
||||||
|
const zoomAtCenter = async () => {
|
||||||
|
overlay.element.dispatchEvent(
|
||||||
|
new WheelEvent('wheel', {
|
||||||
|
deltaY: -4000,
|
||||||
|
clientX: width / 2,
|
||||||
|
clientY: 145,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
flushAnimationFrames()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
await zoomAtCenter()
|
||||||
|
const firstDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||||
|
expect(firstDomain[1] - firstDomain[0]).toBeCloseTo(10)
|
||||||
|
|
||||||
|
await wrapper.setProps({
|
||||||
|
data: {
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: firstDomain[0], y: 0 },
|
||||||
|
{ x: firstDomain[1], y: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
await zoomAtCenter()
|
||||||
|
|
||||||
|
const secondDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||||
|
expect(secondDomain[1] - secondDomain[0]).toBeCloseTo(10)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps one global domain while loading narrower and wider windows', async () => {
|
||||||
|
const wrapper = await mountSizedChart(
|
||||||
|
{
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 100, y: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ displayMode: 'separated', initialXDomain: [0, 100] },
|
||||||
|
)
|
||||||
|
const overlay = wrapper.get('.waveform-chart__overlay')
|
||||||
|
const width = Number(overlay.attributes('width'))
|
||||||
|
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||||
|
value: () => ({ left: 0, top: 0, width, height: 290 }),
|
||||||
|
})
|
||||||
|
const dispatchWheel = async (deltaY: number) => {
|
||||||
|
overlay.element.dispatchEvent(
|
||||||
|
new WheelEvent('wheel', {
|
||||||
|
deltaY,
|
||||||
|
clientX: width / 2,
|
||||||
|
clientY: 145,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
flushAnimationFrames()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
await dispatchWheel(-4000)
|
||||||
|
const zoomedDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||||
|
expect(zoomedDomain[1] - zoomedDomain[0]).toBeCloseTo(2.5)
|
||||||
|
|
||||||
|
await wrapper.setProps({
|
||||||
|
data: {
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 48, y: 0 },
|
||||||
|
{ x: 52, y: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
const preservedDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||||
|
expect(preservedDomain[1] - preservedDomain[0]).toBeCloseTo(2.5)
|
||||||
|
|
||||||
|
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
|
||||||
|
await dispatchWheel(4000)
|
||||||
|
const currentDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||||
|
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount)
|
||||||
|
expect(currentDomain[1] - currentDomain[0]).toBeCloseTo(2.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets a shared viewport on double-click and emits zoom-reset', async () => {
|
||||||
|
const wrapper = await mountSizedChart({
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 2, y: 1 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const overlay = wrapper.get('.waveform-chart__overlay')
|
||||||
|
const width = Number(overlay.attributes('width'))
|
||||||
|
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||||
|
value: () => ({ left: 0, top: 0, width, height: 290 }),
|
||||||
|
})
|
||||||
|
overlay.element.dispatchEvent(
|
||||||
|
new WheelEvent('wheel', {
|
||||||
|
deltaY: -4000,
|
||||||
|
clientX: width / 2,
|
||||||
|
clientY: 145,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
flushAnimationFrames()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe('0.00')
|
||||||
|
|
||||||
|
overlay.element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.emitted('zoom-reset')).toHaveLength(1)
|
||||||
|
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0.00')
|
||||||
|
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('exposes resetViewport for independent tracks', async () => {
|
||||||
|
const wrapper = await mountSizedChart(gridSeries(2), {
|
||||||
|
displayMode: 'independent',
|
||||||
|
grid: { rowCount: 1, columnCount: 2 },
|
||||||
|
})
|
||||||
|
const overlay = wrapper.findAll('.waveform-chart__overlay--independent')[0]
|
||||||
|
const width = Number(overlay.attributes('width'))
|
||||||
|
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||||
|
value: () => ({ left: 0, top: 0, width, height: 260 }),
|
||||||
|
})
|
||||||
|
overlay.element.dispatchEvent(
|
||||||
|
new WheelEvent('wheel', {
|
||||||
|
deltaY: -4000,
|
||||||
|
clientX: width / 2,
|
||||||
|
clientY: 130,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
flushAnimationFrames()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).not.toBe('0.00')
|
||||||
|
|
||||||
|
const chart = wrapper.vm as unknown as { resetViewport: () => void }
|
||||||
|
chart.resetViewport()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0.00')
|
||||||
|
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1.00')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps other independent tracks unchanged when one data window is replaced', async () => {
|
||||||
|
const wrapper = await mountSizedChart(gridSeries(2), {
|
||||||
|
displayMode: 'independent',
|
||||||
|
grid: { rowCount: 1, columnCount: 2 },
|
||||||
|
})
|
||||||
|
const originalEndpoints = wrapper
|
||||||
|
.findAll('.waveform-chart__axis-endpoint--end')
|
||||||
|
.map((endpoint) => endpoint.text())
|
||||||
|
|
||||||
|
await wrapper.setProps({
|
||||||
|
data: {
|
||||||
|
kind: 'series',
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
id: 'channel-0',
|
||||||
|
name: '通道 1',
|
||||||
|
data: {
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 0.25, y: 0 },
|
||||||
|
{ x: 0.75, y: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'channel-1',
|
||||||
|
name: '通道 2',
|
||||||
|
data: {
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 1 },
|
||||||
|
{ x: 1, y: 2 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const nextEndpoints = wrapper
|
||||||
|
.findAll('.waveform-chart__axis-endpoint--end')
|
||||||
|
.map((endpoint) => endpoint.text())
|
||||||
|
expect(nextEndpoints[0]).not.toBe(originalEndpoints[0])
|
||||||
|
expect(nextEndpoints[1]).toBe(originalEndpoints[1])
|
||||||
|
})
|
||||||
|
|
||||||
it('rebuilds cached domains only when the data reference changes', async () => {
|
it('rebuilds cached domains only when the data reference changes', async () => {
|
||||||
const firstData: WaveformData = {
|
const firstData: WaveformData = {
|
||||||
kind: 'points',
|
kind: 'points',
|
||||||
|
|||||||
@@ -92,6 +92,10 @@ const props = withDefaults(
|
|||||||
lineColor?: string
|
lineColor?: string
|
||||||
showTooltip?: boolean
|
showTooltip?: boolean
|
||||||
zoomable?: boolean
|
zoomable?: boolean
|
||||||
|
minZoomSpan?: number
|
||||||
|
minVisiblePoints?: number
|
||||||
|
initialXDomain?: [number, number]
|
||||||
|
initialXDomains?: Record<string, [number, number]>
|
||||||
timeUnit?: 's' | 'ms'
|
timeUnit?: 's' | 'ms'
|
||||||
frameNumber?: string | number
|
frameNumber?: string | number
|
||||||
frameStyle?: WaveformFrameStyle
|
frameStyle?: WaveformFrameStyle
|
||||||
@@ -113,6 +117,7 @@ const props = withDefaults(
|
|||||||
lineColor: '#0960bd',
|
lineColor: '#0960bd',
|
||||||
showTooltip: true,
|
showTooltip: true,
|
||||||
zoomable: true,
|
zoomable: true,
|
||||||
|
minVisiblePoints: 0,
|
||||||
timeUnit: 'ms',
|
timeUnit: 'ms',
|
||||||
frameNumber: undefined,
|
frameNumber: undefined,
|
||||||
annotations: () => [],
|
annotations: () => [],
|
||||||
@@ -130,6 +135,7 @@ const emit = defineEmits<{
|
|||||||
'point-hover': [point: WaveformPoint | null]
|
'point-hover': [point: WaveformPoint | null]
|
||||||
'zoom-change': [domain: [number, number]]
|
'zoom-change': [domain: [number, number]]
|
||||||
'zoom-end': [payload: WaveformZoomEndPayload]
|
'zoom-end': [payload: WaveformZoomEndPayload]
|
||||||
|
'zoom-reset': []
|
||||||
'update:annotations': [annotations: WaveformAnnotation[]]
|
'update:annotations': [annotations: WaveformAnnotation[]]
|
||||||
'update:annotations-visible': [visible: boolean]
|
'update:annotations-visible': [visible: boolean]
|
||||||
'update:interaction-mode': [mode: WaveformInteractionMode]
|
'update:interaction-mode': [mode: WaveformInteractionMode]
|
||||||
@@ -159,6 +165,8 @@ const measuredTitleWidth = ref(0)
|
|||||||
const measuredTitleHeight = ref(0)
|
const measuredTitleHeight = ref(0)
|
||||||
const sharedTransform = shallowRef<ZoomTransform>(zoomIdentity)
|
const sharedTransform = shallowRef<ZoomTransform>(zoomIdentity)
|
||||||
const independentTransforms = shallowRef<ZoomTransform[]>([])
|
const independentTransforms = shallowRef<ZoomTransform[]>([])
|
||||||
|
const sharedYDomains = ref<Record<string, [number, number]>>({})
|
||||||
|
const independentYDomains = ref<Record<number, [number, number]>>({})
|
||||||
const hoveredSeriesPoints = ref<HoveredSeriesPoint[]>([])
|
const hoveredSeriesPoints = ref<HoveredSeriesPoint[]>([])
|
||||||
const hoveredTrackIndex = ref<number | null>(null)
|
const hoveredTrackIndex = ref<number | null>(null)
|
||||||
const hoverPosition = ref({ x: 0, y: 0 })
|
const hoverPosition = ref({ x: 0, y: 0 })
|
||||||
@@ -174,12 +182,53 @@ const editorSeriesOptions = ref<AnnotationSeriesCandidate[]>([])
|
|||||||
let generatedAnnotationId = 0
|
let generatedAnnotationId = 0
|
||||||
let synchronizingZoomTransform = false
|
let synchronizingZoomTransform = false
|
||||||
let pendingSharedZoomTransform: ZoomTransform | null = null
|
let pendingSharedZoomTransform: ZoomTransform | null = null
|
||||||
|
type ZoomGestureKind = 'wheel'
|
||||||
|
let pendingSharedZoomGesture: ZoomGestureKind | null = null
|
||||||
|
let lastSharedZoomGesture: ZoomGestureKind | null = null
|
||||||
const pendingIndependentZoomTransforms = new Map<number, ZoomTransform>()
|
const pendingIndependentZoomTransforms = new Map<number, ZoomTransform>()
|
||||||
|
const pendingIndependentZoomGestures = new Map<number, ZoomGestureKind>()
|
||||||
|
const lastIndependentZoomGestures = new Map<number, ZoomGestureKind>()
|
||||||
const lastZoomedTrackIndexes = new Set<number>()
|
const lastZoomedTrackIndexes = new Set<number>()
|
||||||
const zoomThrottle = useAnimationFrameThrottle()
|
const zoomThrottle = useAnimationFrameThrottle()
|
||||||
const hoverThrottle = useAnimationFrameThrottle()
|
const hoverThrottle = useAnimationFrameThrottle()
|
||||||
const preparedSeries = usePreparedWaveformSeries(() => props.data, handleDataReferenceChange)
|
const preparedSeries = usePreparedWaveformSeries(() => props.data, handleDataReferenceChange)
|
||||||
|
|
||||||
|
interface SelectionState {
|
||||||
|
trackIndex: number
|
||||||
|
independent: boolean
|
||||||
|
overlay: SVGRectElement
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
currentX: number
|
||||||
|
currentY: number
|
||||||
|
pointerId: number
|
||||||
|
mode: 'box' | 'pan'
|
||||||
|
xDomain: [number, number]
|
||||||
|
yDomains: Record<string, [number, number]>
|
||||||
|
}
|
||||||
|
|
||||||
|
const selection = ref<SelectionState | null>(null)
|
||||||
|
const spacePressed = ref(false)
|
||||||
|
const selectionBox = computed(() => {
|
||||||
|
const active = selection.value
|
||||||
|
if (!active) return null
|
||||||
|
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
|
||||||
|
return {
|
||||||
|
x: Math.min(active.startX, active.currentX) + (active.independent ? (track?.left ?? 0) : 0),
|
||||||
|
y: Math.min(active.startY, active.currentY) + (active.independent ? (track?.top ?? 0) : 0),
|
||||||
|
width: Math.abs(active.currentX - active.startX),
|
||||||
|
height: Math.abs(active.currentY - active.startY),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
function handleInteractionKeyDown(event: KeyboardEvent) {
|
||||||
|
if (event.code === 'Space') spacePressed.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleInteractionKeyUp(event: KeyboardEvent) {
|
||||||
|
if (event.code === 'Space') spacePressed.value = false
|
||||||
|
}
|
||||||
|
|
||||||
// 用于传递给 WaveformTooltip 的接口
|
// 用于传递给 WaveformTooltip 的接口
|
||||||
interface TooltipSeriesPoint {
|
interface TooltipSeriesPoint {
|
||||||
trackIndex: number
|
trackIndex: number
|
||||||
@@ -475,10 +524,39 @@ const sharedXDomain = computed(() =>
|
|||||||
chartTracks.value.flatMap((track) => (track.visibleSeries.length ? track.xDomain : [])),
|
chartTracks.value.flatMap((track) => (track.visibleSeries.length ? track.xDomain : [])),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
const initialXDomain = computed<[number, number]>(() => {
|
||||||
|
const domain = props.initialXDomain
|
||||||
|
if (
|
||||||
|
domain &&
|
||||||
|
Number.isFinite(domain[0]) &&
|
||||||
|
Number.isFinite(domain[1]) &&
|
||||||
|
domain[0] !== domain[1]
|
||||||
|
) {
|
||||||
|
return domain[0] < domain[1] ? domain : [domain[1], domain[0]]
|
||||||
|
}
|
||||||
|
return sharedXDomain.value
|
||||||
|
})
|
||||||
|
function resolveInitialTrackDomain(track: TrackLayout): [number, number] {
|
||||||
|
const configuredDomain =
|
||||||
|
props.initialXDomains?.[track.series.trackId ?? track.series.id] ??
|
||||||
|
props.initialXDomains?.[track.series.id] ??
|
||||||
|
props.initialXDomain
|
||||||
|
if (
|
||||||
|
configuredDomain &&
|
||||||
|
Number.isFinite(configuredDomain[0]) &&
|
||||||
|
Number.isFinite(configuredDomain[1]) &&
|
||||||
|
configuredDomain[0] !== configuredDomain[1]
|
||||||
|
) {
|
||||||
|
return configuredDomain[0] < configuredDomain[1]
|
||||||
|
? configuredDomain
|
||||||
|
: [configuredDomain[1], configuredDomain[0]]
|
||||||
|
}
|
||||||
|
return paddedDomain(track.seriesList.flatMap((series) => series.xDomain))
|
||||||
|
}
|
||||||
const sharedZoomDomain = computed(
|
const sharedZoomDomain = computed(
|
||||||
() =>
|
() =>
|
||||||
sharedTransform.value
|
sharedTransform.value
|
||||||
.rescaleX(scaleLinear(sharedXDomain.value, [0, innerWidth.value]))
|
.rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value]))
|
||||||
.domain() as [number, number],
|
.domain() as [number, number],
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -502,6 +580,17 @@ const trackLayouts = computed<TrackLayout[]>(() =>
|
|||||||
overlayMode: props.overlayMode,
|
overlayMode: props.overlayMode,
|
||||||
independentTransforms: independentTransforms.value,
|
independentTransforms: independentTransforms.value,
|
||||||
sharedZoomDomain: sharedZoomDomain.value,
|
sharedZoomDomain: sharedZoomDomain.value,
|
||||||
|
initialXDomain: props.initialXDomain ? initialXDomain.value : undefined,
|
||||||
|
initialXDomains: props.initialXDomains,
|
||||||
|
yDomains:
|
||||||
|
props.displayMode === 'independent'
|
||||||
|
? Object.fromEntries(
|
||||||
|
chartTracks.value.flatMap((track, index) => {
|
||||||
|
const domain = independentYDomains.value[index]
|
||||||
|
return domain ? [[track.id, domain]] : []
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: sharedYDomains.value,
|
||||||
timeUnit: props.timeUnit,
|
timeUnit: props.timeUnit,
|
||||||
rendering: renderingOptions.value,
|
rendering: renderingOptions.value,
|
||||||
hideSecondaryLabels: yAxisLayout.value.hideSecondaryLabels,
|
hideSecondaryLabels: yAxisLayout.value.hideSecondaryLabels,
|
||||||
@@ -568,6 +657,7 @@ function handleSharedZoom(event: D3ZoomEvent<SVGRectElement, unknown>) {
|
|||||||
if (synchronizingZoomTransform) return
|
if (synchronizingZoomTransform) return
|
||||||
cancelPendingHover()
|
cancelPendingHover()
|
||||||
pendingSharedZoomTransform = event.transform
|
pendingSharedZoomTransform = event.transform
|
||||||
|
pendingSharedZoomGesture = 'wheel'
|
||||||
scheduleZoomCommit()
|
scheduleZoomCommit()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -575,6 +665,7 @@ function handleIndependentZoom(event: D3ZoomEvent<SVGRectElement, unknown>, trac
|
|||||||
if (synchronizingZoomTransform) return
|
if (synchronizingZoomTransform) return
|
||||||
cancelPendingHover()
|
cancelPendingHover()
|
||||||
pendingIndependentZoomTransforms.set(trackIndex, event.transform)
|
pendingIndependentZoomTransforms.set(trackIndex, event.transform)
|
||||||
|
pendingIndependentZoomGestures.set(trackIndex, 'wheel')
|
||||||
scheduleZoomCommit()
|
scheduleZoomCommit()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,9 +673,11 @@ function commitPendingZoom() {
|
|||||||
if (pendingSharedZoomTransform) {
|
if (pendingSharedZoomTransform) {
|
||||||
const transform = pendingSharedZoomTransform
|
const transform = pendingSharedZoomTransform
|
||||||
pendingSharedZoomTransform = null
|
pendingSharedZoomTransform = null
|
||||||
|
lastSharedZoomGesture = pendingSharedZoomGesture
|
||||||
|
pendingSharedZoomGesture = null
|
||||||
sharedTransform.value = transform
|
sharedTransform.value = transform
|
||||||
const domain = transform
|
const domain = transform
|
||||||
.rescaleX(scaleLinear(sharedXDomain.value, [0, innerWidth.value]))
|
.rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value]))
|
||||||
.domain()
|
.domain()
|
||||||
emit('zoom-change', [domain[0], domain[1]])
|
emit('zoom-change', [domain[0], domain[1]])
|
||||||
}
|
}
|
||||||
@@ -595,10 +688,15 @@ function commitPendingZoom() {
|
|||||||
// Clear stale track indexes before recording the new batch
|
// Clear stale track indexes before recording the new batch
|
||||||
lastZoomedTrackIndexes.clear()
|
lastZoomedTrackIndexes.clear()
|
||||||
changedTrackIndexes.forEach((trackIndex) => lastZoomedTrackIndexes.add(trackIndex))
|
changedTrackIndexes.forEach((trackIndex) => lastZoomedTrackIndexes.add(trackIndex))
|
||||||
|
changedTrackIndexes.forEach((trackIndex) => {
|
||||||
|
const gesture = pendingIndependentZoomGestures.get(trackIndex)
|
||||||
|
if (gesture) lastIndependentZoomGestures.set(trackIndex, gesture)
|
||||||
|
})
|
||||||
pendingIndependentZoomTransforms.forEach((transform, trackIndex) => {
|
pendingIndependentZoomTransforms.forEach((transform, trackIndex) => {
|
||||||
nextTransforms[trackIndex] = transform
|
nextTransforms[trackIndex] = transform
|
||||||
})
|
})
|
||||||
pendingIndependentZoomTransforms.clear()
|
pendingIndependentZoomTransforms.clear()
|
||||||
|
pendingIndependentZoomGestures.clear()
|
||||||
independentTransforms.value = nextTransforms
|
independentTransforms.value = nextTransforms
|
||||||
changedTrackIndexes.forEach((trackIndex) => {
|
changedTrackIndexes.forEach((trackIndex) => {
|
||||||
const track = trackLayouts.value.find((item) => item.index === trackIndex)
|
const track = trackLayouts.value.find((item) => item.index === trackIndex)
|
||||||
@@ -622,29 +720,58 @@ function flushPendingZoom() {
|
|||||||
function emitZoomEnd() {
|
function emitZoomEnd() {
|
||||||
if (props.displayMode === 'independent') {
|
if (props.displayMode === 'independent') {
|
||||||
lastZoomedTrackIndexes.forEach((trackIndex) => {
|
lastZoomedTrackIndexes.forEach((trackIndex) => {
|
||||||
|
const gesture = lastIndependentZoomGestures.get(trackIndex)
|
||||||
|
if (gesture !== 'wheel') return
|
||||||
const track = trackLayouts.value.find((item) => item.index === trackIndex)
|
const track = trackLayouts.value.find((item) => item.index === trackIndex)
|
||||||
if (!track) return
|
if (!track) return
|
||||||
const domain = track.xScale.domain() as [number, number]
|
const domain = track.xScale.domain() as [number, number]
|
||||||
emit('zoom-end', {
|
emit('zoom-end', {
|
||||||
start: domain[0],
|
start: domain[0],
|
||||||
end: domain[1],
|
end: domain[1],
|
||||||
|
yStart: track.yScale.domain()[0],
|
||||||
|
yEnd: track.yScale.domain()[1],
|
||||||
trackIndex,
|
trackIndex,
|
||||||
seriesIds: track.seriesList.map((series) => series.id),
|
seriesIds: track.seriesList.map((series) => series.id),
|
||||||
|
gesture: 'wheel',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
lastZoomedTrackIndexes.clear()
|
lastZoomedTrackIndexes.clear()
|
||||||
|
lastIndependentZoomGestures.clear()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (lastSharedZoomGesture !== 'wheel') {
|
||||||
|
lastSharedZoomGesture = null
|
||||||
|
return
|
||||||
|
}
|
||||||
const domain = sharedZoomDomain.value
|
const domain = sharedZoomDomain.value
|
||||||
emit('zoom-end', { start: domain[0], end: domain[1] })
|
const visibleTracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
|
||||||
|
const payload: WaveformZoomEndPayload = { start: domain[0], end: domain[1], gesture: 'wheel' }
|
||||||
|
if (visibleTracks.length === 1) {
|
||||||
|
const yDomain = visibleTracks[0]?.yScale.domain()
|
||||||
|
payload.yStart = yDomain?.[0]
|
||||||
|
payload.yEnd = yDomain?.[1]
|
||||||
|
} else {
|
||||||
|
payload.yRanges = Object.fromEntries(
|
||||||
|
visibleTracks.map((track) => [
|
||||||
|
track.series.trackId ?? track.series.id,
|
||||||
|
track.yScale.domain() as [number, number],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
emit('zoom-end', payload)
|
||||||
|
lastSharedZoomGesture = null
|
||||||
}
|
}
|
||||||
|
|
||||||
function cancelPendingZoom() {
|
function cancelPendingZoom() {
|
||||||
// Clear all pending zoom state to prevent stale emissions
|
// Clear all pending zoom state to prevent stale emissions
|
||||||
pendingSharedZoomTransform = null
|
pendingSharedZoomTransform = null
|
||||||
|
pendingSharedZoomGesture = null
|
||||||
|
lastSharedZoomGesture = null
|
||||||
pendingIndependentZoomTransforms.clear()
|
pendingIndependentZoomTransforms.clear()
|
||||||
|
pendingIndependentZoomGestures.clear()
|
||||||
lastZoomedTrackIndexes.clear()
|
lastZoomedTrackIndexes.clear()
|
||||||
|
lastIndependentZoomGestures.clear()
|
||||||
zoomThrottle.cancel()
|
zoomThrottle.cancel()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -660,6 +787,35 @@ function clearZoomBindings() {
|
|||||||
zoomBehaviors.clear()
|
zoomBehaviors.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveMaximumZoomScale(domain: [number, number]): number {
|
||||||
|
const minZoomSpan = props.minZoomSpan
|
||||||
|
if (!Number.isFinite(minZoomSpan) || (minZoomSpan ?? 0) <= 0) return 40
|
||||||
|
const domainSpan = Math.abs(domain[1] - domain[0])
|
||||||
|
if (!Number.isFinite(domainSpan) || domainSpan <= 0) return 1
|
||||||
|
return Math.min(40, Math.max(1, domainSpan / (minZoomSpan ?? domainSpan)))
|
||||||
|
}
|
||||||
|
|
||||||
|
function visiblePointCount(track: TrackLayout): number {
|
||||||
|
const [start, end] = track.xScale.domain()
|
||||||
|
const xValues = new Set<number>()
|
||||||
|
track.seriesList.forEach((series) => {
|
||||||
|
series.points.forEach((point) => {
|
||||||
|
if (point.x >= start && point.x <= end) xValues.add(point.x)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return xValues.size
|
||||||
|
}
|
||||||
|
|
||||||
|
function canZoomTrack(track: TrackLayout): boolean {
|
||||||
|
const minimum = Number(props.minVisiblePoints)
|
||||||
|
return !Number.isFinite(minimum) || minimum <= 0 || visiblePointCount(track) >= minimum
|
||||||
|
}
|
||||||
|
|
||||||
|
function canZoomSharedTracks(): boolean {
|
||||||
|
const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
|
||||||
|
return tracks.length > 0 && tracks.every(canZoomTrack)
|
||||||
|
}
|
||||||
|
|
||||||
function configureZoom() {
|
function configureZoom() {
|
||||||
clearZoomBindings()
|
clearZoomBindings()
|
||||||
if (!props.zoomable || !isZoomMode.value || !hasChartArea.value || !trackLayouts.value.length)
|
if (!props.zoomable || !isZoomMode.value || !hasChartArea.value || !trackLayouts.value.length)
|
||||||
@@ -671,8 +827,13 @@ function configureZoom() {
|
|||||||
`[data-independent-overlay-index="${track.index}"]`,
|
`[data-independent-overlay-index="${track.index}"]`,
|
||||||
)
|
)
|
||||||
if (!overlay) return
|
if (!overlay) return
|
||||||
|
const dataDomain = resolveInitialTrackDomain(track)
|
||||||
const behavior = zoom<SVGRectElement, unknown>()
|
const behavior = zoom<SVGRectElement, unknown>()
|
||||||
.scaleExtent([1, 40])
|
.filter(
|
||||||
|
(event) =>
|
||||||
|
event.type === 'wheel' && (event as WheelEvent).deltaY < 0 && canZoomTrack(track),
|
||||||
|
)
|
||||||
|
.scaleExtent([1, resolveMaximumZoomScale(dataDomain)])
|
||||||
.extent([
|
.extent([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[track.width, track.height],
|
[track.width, track.height],
|
||||||
@@ -688,6 +849,7 @@ function configureZoom() {
|
|||||||
try {
|
try {
|
||||||
select(overlay)
|
select(overlay)
|
||||||
.call(behavior)
|
.call(behavior)
|
||||||
|
.on('dblclick.zoom', null)
|
||||||
.call(behavior.transform, independentTransforms.value[track.index] ?? zoomIdentity)
|
.call(behavior.transform, independentTransforms.value[track.index] ?? zoomIdentity)
|
||||||
} finally {
|
} finally {
|
||||||
synchronizingZoomTransform = false
|
synchronizingZoomTransform = false
|
||||||
@@ -698,7 +860,11 @@ function configureZoom() {
|
|||||||
|
|
||||||
if (!sharedOverlayElement.value) return
|
if (!sharedOverlayElement.value) return
|
||||||
const behavior = zoom<SVGRectElement, unknown>()
|
const behavior = zoom<SVGRectElement, unknown>()
|
||||||
.scaleExtent([1, 40])
|
.filter(
|
||||||
|
(event) =>
|
||||||
|
event.type === 'wheel' && (event as WheelEvent).deltaY < 0 && canZoomSharedTracks(),
|
||||||
|
)
|
||||||
|
.scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value)])
|
||||||
.extent([
|
.extent([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[innerWidth.value, innerHeight.value],
|
[innerWidth.value, innerHeight.value],
|
||||||
@@ -714,7 +880,10 @@ function configureZoom() {
|
|||||||
if (overlay) {
|
if (overlay) {
|
||||||
synchronizingZoomTransform = true
|
synchronizingZoomTransform = true
|
||||||
try {
|
try {
|
||||||
select(overlay).call(behavior).call(behavior.transform, sharedTransform.value)
|
select(overlay)
|
||||||
|
.call(behavior)
|
||||||
|
.on('dblclick.zoom', null)
|
||||||
|
.call(behavior.transform, sharedTransform.value)
|
||||||
} finally {
|
} finally {
|
||||||
synchronizingZoomTransform = false
|
synchronizingZoomTransform = false
|
||||||
}
|
}
|
||||||
@@ -1100,6 +1269,10 @@ function confirmAnnotation(annotation: WaveformAnnotation) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleIndependentPointerMove(event: PointerEvent, trackIndex: number) {
|
function handleIndependentPointerMove(event: PointerEvent, trackIndex: number) {
|
||||||
|
if (selection.value?.trackIndex === trackIndex && selection.value.independent) {
|
||||||
|
updateViewportDrag(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (consumeHoverSuppression()) return
|
if (consumeHoverSuppression()) return
|
||||||
const overlay = event.currentTarget as SVGRectElement | null
|
const overlay = event.currentTarget as SVGRectElement | null
|
||||||
if (!overlay) return
|
if (!overlay) return
|
||||||
@@ -1126,6 +1299,10 @@ function handleIndependentPointerMove(event: PointerEvent, trackIndex: number) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleSharedPointerMove(event: PointerEvent) {
|
function handleSharedPointerMove(event: PointerEvent) {
|
||||||
|
if (selection.value?.overlay === event.currentTarget) {
|
||||||
|
updateViewportDrag(event)
|
||||||
|
return
|
||||||
|
}
|
||||||
if (consumeHoverSuppression()) return
|
if (consumeHoverSuppression()) return
|
||||||
if (!sharedOverlayElement.value || !trackLayouts.value.length) return
|
if (!sharedOverlayElement.value || !trackLayouts.value.length) return
|
||||||
const [pointerX, pointerY] = pointer(event, sharedOverlayElement.value)
|
const [pointerX, pointerY] = pointer(event, sharedOverlayElement.value)
|
||||||
@@ -1152,15 +1329,260 @@ function handleSharedPointerMove(event: PointerEvent) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetViewport() {
|
const minimumSelectionSize = 6
|
||||||
|
|
||||||
|
function transformForDomain(
|
||||||
|
domain: [number, number],
|
||||||
|
baseDomain: [number, number],
|
||||||
|
width: number,
|
||||||
|
): ZoomTransform {
|
||||||
|
const baseSpan = baseDomain[1] - baseDomain[0]
|
||||||
|
const span = domain[1] - domain[0]
|
||||||
|
if (!Number.isFinite(baseSpan) || !Number.isFinite(span) || baseSpan <= 0 || span <= 0) {
|
||||||
|
return zoomIdentity
|
||||||
|
}
|
||||||
|
const scale = baseSpan / span
|
||||||
|
const baseScale = scaleLinear(baseDomain, [0, width])
|
||||||
|
return zoomIdentity.translate(-scale * baseScale(domain[0]), 0).scale(scale)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveMinimumZoomSpan(boundary: [number, number]): number {
|
||||||
|
const boundarySpan = Math.abs(boundary[1] - boundary[0])
|
||||||
|
if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return 0
|
||||||
|
const configured = props.minZoomSpan
|
||||||
|
if (Number.isFinite(configured) && (configured ?? 0) > 0) {
|
||||||
|
return Math.min(boundarySpan, configured as number)
|
||||||
|
}
|
||||||
|
return boundarySpan / 40
|
||||||
|
}
|
||||||
|
|
||||||
|
function constrainZoomDomain(
|
||||||
|
domain: [number, number],
|
||||||
|
boundary: [number, number],
|
||||||
|
): [number, number] {
|
||||||
|
const normalizedBoundary: [number, number] =
|
||||||
|
boundary[0] <= boundary[1] ? [...boundary] : [boundary[1], boundary[0]]
|
||||||
|
const boundarySpan = normalizedBoundary[1] - normalizedBoundary[0]
|
||||||
|
if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return normalizedBoundary
|
||||||
|
const requestedStart = Math.min(domain[0], domain[1])
|
||||||
|
const requestedEnd = Math.max(domain[0], domain[1])
|
||||||
|
const minimumSpan = resolveMinimumZoomSpan(normalizedBoundary)
|
||||||
|
const span = Math.max(minimumSpan, Math.min(boundarySpan, requestedEnd - requestedStart))
|
||||||
|
const center = (requestedStart + requestedEnd) / 2
|
||||||
|
const start = Math.max(
|
||||||
|
normalizedBoundary[0],
|
||||||
|
Math.min(center - span / 2, normalizedBoundary[1] - span),
|
||||||
|
)
|
||||||
|
return [start, start + span]
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampDomain(domain: [number, number], boundary: [number, number]): [number, number] {
|
||||||
|
const span = domain[1] - domain[0]
|
||||||
|
const boundarySpan = boundary[1] - boundary[0]
|
||||||
|
if (span >= boundarySpan) return [...boundary]
|
||||||
|
if (domain[0] < boundary[0]) return [boundary[0], boundary[0] + span]
|
||||||
|
if (domain[1] > boundary[1]) return [boundary[1] - span, boundary[1]]
|
||||||
|
return domain
|
||||||
|
}
|
||||||
|
|
||||||
|
function currentYDomains(): Record<string, [number, number]> {
|
||||||
|
return Object.fromEntries(
|
||||||
|
trackLayouts.value.map((track) => [
|
||||||
|
track.series.trackId ?? track.series.id,
|
||||||
|
track.yScale.domain() as [number, number],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginViewportDrag(event: PointerEvent, trackIndex: number, independent: boolean) {
|
||||||
|
if (!props.zoomable || !isZoomMode.value || event.button !== 0) return
|
||||||
|
const overlay = event.currentTarget as SVGRectElement
|
||||||
|
const track = trackLayouts.value.find((item) => item.index === trackIndex)
|
||||||
|
if (!track) return
|
||||||
|
const [rawX, rawY] = pointer(event, overlay)
|
||||||
|
const x = Math.max(0, Math.min(independent ? track.width : innerWidth.value, rawX))
|
||||||
|
const y = Math.max(0, Math.min(independent ? track.height : innerHeight.value, rawY))
|
||||||
|
selection.value = {
|
||||||
|
trackIndex,
|
||||||
|
independent,
|
||||||
|
overlay,
|
||||||
|
startX: x,
|
||||||
|
startY: y,
|
||||||
|
currentX: x,
|
||||||
|
currentY: y,
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
mode: spacePressed.value ? 'pan' : 'box',
|
||||||
|
xDomain: track.xScale.domain() as [number, number],
|
||||||
|
yDomains: currentYDomains(),
|
||||||
|
}
|
||||||
|
overlay.setPointerCapture?.(event.pointerId)
|
||||||
|
clearHover()
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
function beginSharedViewportDrag(event: PointerEvent) {
|
||||||
|
if (!sharedOverlayElement.value) return
|
||||||
|
const [x, y] = pointer(event, sharedOverlayElement.value)
|
||||||
|
const track =
|
||||||
|
resolveTrackAtPointer(x, y) ?? trackLayouts.value.find((item) => item.hasVisibleSeries)
|
||||||
|
if (track) beginViewportDrag(event, track.index, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateViewportDrag(event: PointerEvent) {
|
||||||
|
const active = selection.value
|
||||||
|
if (!active || event.pointerId !== active.pointerId) return
|
||||||
|
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
|
||||||
|
if (!track) return
|
||||||
|
const [rawX, rawY] = pointer(event, active.overlay)
|
||||||
|
active.currentX = Math.max(0, Math.min(active.independent ? track.width : innerWidth.value, rawX))
|
||||||
|
active.currentY = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(active.independent ? track.height : innerHeight.value, rawY),
|
||||||
|
)
|
||||||
|
selection.value = { ...active }
|
||||||
|
if (active.mode === 'pan') applyPan(active, track)
|
||||||
|
event.preventDefault()
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPan(active: SelectionState, track: TrackLayout) {
|
||||||
|
const width = track.width || 1
|
||||||
|
const height = track.height || 1
|
||||||
|
const dx = active.currentX - active.startX
|
||||||
|
const dy = active.currentY - active.startY
|
||||||
|
const xSpan = active.xDomain[1] - active.xDomain[0]
|
||||||
|
const initialDomain = active.independent ? resolveInitialTrackDomain(track) : initialXDomain.value
|
||||||
|
const nextX = clampDomain(
|
||||||
|
[active.xDomain[0] - (dx / width) * xSpan, active.xDomain[1] - (dx / width) * xSpan],
|
||||||
|
initialDomain,
|
||||||
|
)
|
||||||
|
if (active.independent) {
|
||||||
|
const next = [...independentTransforms.value]
|
||||||
|
next[track.index] = transformForDomain(nextX, initialDomain, width)
|
||||||
|
independentTransforms.value = next
|
||||||
|
} else {
|
||||||
|
sharedTransform.value = transformForDomain(nextX, initialDomain, innerWidth.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = active.independent ? [track] : trackLayouts.value
|
||||||
|
targets.forEach((target) => {
|
||||||
|
const key = target.series.trackId ?? target.series.id
|
||||||
|
const source = active.yDomains[key] ?? (target.yScale.domain() as [number, number])
|
||||||
|
const boundary = chartTracks.value.find((item) => item.id === key)?.yDomain ?? source
|
||||||
|
const ySpan = source[1] - source[0]
|
||||||
|
const nextY = clampDomain(
|
||||||
|
[source[0] + (dy / height) * ySpan, source[1] + (dy / height) * ySpan],
|
||||||
|
boundary,
|
||||||
|
)
|
||||||
|
if (active.independent)
|
||||||
|
independentYDomains.value = { ...independentYDomains.value, [target.index]: nextY }
|
||||||
|
else sharedYDomains.value = { ...sharedYDomains.value, [key]: nextY }
|
||||||
|
})
|
||||||
|
emit('zoom-change', nextX)
|
||||||
|
}
|
||||||
|
|
||||||
|
function finishViewportDrag(event: PointerEvent) {
|
||||||
|
const active = selection.value
|
||||||
|
if (!active || event.pointerId !== active.pointerId) return
|
||||||
|
updateViewportDrag(event)
|
||||||
|
active.overlay.releasePointerCapture?.(active.pointerId)
|
||||||
|
selection.value = null
|
||||||
|
if (active.mode === 'pan') {
|
||||||
|
void nextTick(configureZoom)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const width = Math.abs(active.currentX - active.startX)
|
||||||
|
if (width < minimumSelectionSize) return
|
||||||
|
applyBoxZoom(active)
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelViewportDrag(event?: PointerEvent) {
|
||||||
|
const active = selection.value
|
||||||
|
if (!active || (event && event.pointerId !== active.pointerId)) return
|
||||||
|
active.overlay.releasePointerCapture?.(active.pointerId)
|
||||||
|
selection.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyBoxZoom(active: SelectionState) {
|
||||||
|
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
|
||||||
|
if (!track || (active.independent ? !canZoomTrack(track) : !canZoomSharedTracks())) return
|
||||||
|
const offsetX = active.independent ? 0 : track.left
|
||||||
|
const left = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(track.width, Math.min(active.startX, active.currentX) - offsetX),
|
||||||
|
)
|
||||||
|
const right = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(track.width, Math.max(active.startX, active.currentX) - offsetX),
|
||||||
|
)
|
||||||
|
if (right - left < minimumSelectionSize) return
|
||||||
|
const baseXDomain = active.independent ? resolveInitialTrackDomain(track) : initialXDomain.value
|
||||||
|
const xDomain = constrainZoomDomain(
|
||||||
|
[track.xScale.invert(left), track.xScale.invert(right)],
|
||||||
|
baseXDomain,
|
||||||
|
)
|
||||||
|
if (active.independent) {
|
||||||
|
const next = [...independentTransforms.value]
|
||||||
|
next[track.index] = transformForDomain(xDomain, baseXDomain, track.width)
|
||||||
|
independentTransforms.value = next
|
||||||
|
} else {
|
||||||
|
sharedTransform.value = transformForDomain(xDomain, baseXDomain, innerWidth.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const targets = active.independent ? [track] : trackLayouts.value
|
||||||
|
const yRanges: Record<string, [number, number]> = Object.fromEntries(
|
||||||
|
targets.map((target) => [
|
||||||
|
target.series.trackId ?? target.series.id,
|
||||||
|
target.yScale.domain() as [number, number],
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
|
||||||
|
emit('zoom-change', xDomain)
|
||||||
|
const payload: WaveformZoomEndPayload = { start: xDomain[0], end: xDomain[1], gesture: 'box' }
|
||||||
|
if (active.independent) {
|
||||||
|
const yDomain = yRanges[track.series.trackId ?? track.series.id]
|
||||||
|
payload.trackIndex = track.index
|
||||||
|
payload.seriesIds = track.seriesList.map((series) => series.id)
|
||||||
|
payload.yStart = yDomain?.[0]
|
||||||
|
payload.yEnd = yDomain?.[1]
|
||||||
|
} else if (targets.length === 1) {
|
||||||
|
const yDomain = Object.values(yRanges)[0]
|
||||||
|
payload.yStart = yDomain?.[0]
|
||||||
|
payload.yEnd = yDomain?.[1]
|
||||||
|
} else payload.yRanges = yRanges
|
||||||
|
emit('zoom-end', payload)
|
||||||
|
void nextTick(configureZoom)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetViewport(trackIndex?: number) {
|
||||||
cancelPendingZoom()
|
cancelPendingZoom()
|
||||||
sharedTransform.value = zoomIdentity
|
cancelViewportDrag()
|
||||||
independentTransforms.value = chartTracks.value.map(() => zoomIdentity)
|
if (props.displayMode === 'independent' && trackIndex !== undefined) {
|
||||||
|
const nextTransforms = [...independentTransforms.value]
|
||||||
|
nextTransforms[trackIndex] = zoomIdentity
|
||||||
|
independentTransforms.value = nextTransforms
|
||||||
|
const nextYDomains = { ...independentYDomains.value }
|
||||||
|
delete nextYDomains[trackIndex]
|
||||||
|
independentYDomains.value = nextYDomains
|
||||||
|
} else {
|
||||||
|
sharedTransform.value = zoomIdentity
|
||||||
|
independentTransforms.value = chartTracks.value.map(() => zoomIdentity)
|
||||||
|
sharedYDomains.value = {}
|
||||||
|
independentYDomains.value = {}
|
||||||
|
}
|
||||||
clearHover()
|
clearHover()
|
||||||
editorSeriesOptions.value = []
|
editorSeriesOptions.value = []
|
||||||
void nextTick(configureZoom)
|
void nextTick(configureZoom)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function requestViewportReset(event: MouseEvent) {
|
||||||
|
if (!props.zoomable || !isZoomMode.value) return
|
||||||
|
event.preventDefault()
|
||||||
|
resetViewport()
|
||||||
|
emit('zoom-reset')
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({ resetViewport })
|
||||||
|
|
||||||
function goToPage(page: number) {
|
function goToPage(page: number) {
|
||||||
const nextPage = Math.min(pageCount.value, Math.max(1, Math.floor(page)))
|
const nextPage = Math.min(pageCount.value, Math.max(1, Math.floor(page)))
|
||||||
if (nextPage === currentPage.value) return
|
if (nextPage === currentPage.value) return
|
||||||
@@ -1170,6 +1592,7 @@ function goToPage(page: number) {
|
|||||||
cancelAnnotation()
|
cancelAnnotation()
|
||||||
if (props.displayMode === 'independent') {
|
if (props.displayMode === 'independent') {
|
||||||
independentTransforms.value = pagedTracks.value.map(() => zoomIdentity)
|
independentTransforms.value = pagedTracks.value.map(() => zoomIdentity)
|
||||||
|
independentYDomains.value = {}
|
||||||
}
|
}
|
||||||
void nextTick(configureZoom)
|
void nextTick(configureZoom)
|
||||||
emit('page-change', nextPage, pageCount.value)
|
emit('page-change', nextPage, pageCount.value)
|
||||||
@@ -1180,6 +1603,9 @@ watch(
|
|||||||
innerWidth,
|
innerWidth,
|
||||||
innerHeight,
|
innerHeight,
|
||||||
() => props.zoomable,
|
() => props.zoomable,
|
||||||
|
() => props.minZoomSpan,
|
||||||
|
() => props.initialXDomain,
|
||||||
|
() => props.initialXDomains,
|
||||||
() => props.displayMode,
|
() => props.displayMode,
|
||||||
() => chartTracks.value.length,
|
() => chartTracks.value.length,
|
||||||
() => currentPage.value,
|
() => currentPage.value,
|
||||||
@@ -1195,10 +1621,20 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
function handleDataReferenceChange() {
|
function handleDataReferenceChange() {
|
||||||
const previousPage = currentPage.value
|
if (props.displayMode === 'independent') {
|
||||||
currentPage.value = 1
|
const currentTransforms = independentTransforms.value
|
||||||
resetViewport()
|
independentTransforms.value = chartTracks.value.map(
|
||||||
if (previousPage !== 1) emit('page-change', 1, pageCount.value)
|
(_track, index) => currentTransforms[index] ?? zoomIdentity,
|
||||||
|
)
|
||||||
|
clearHover()
|
||||||
|
editorSeriesOptions.value = []
|
||||||
|
void nextTick(configureZoom)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
clearHover()
|
||||||
|
editorSeriesOptions.value = []
|
||||||
|
void nextTick(configureZoom)
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -1321,6 +1757,8 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', handleInteractionKeyDown)
|
||||||
|
window.addEventListener('keyup', handleInteractionKeyUp)
|
||||||
if (!container.value) return
|
if (!container.value) return
|
||||||
resizeObserver.value = new ResizeObserver(([entry]) => {
|
resizeObserver.value = new ResizeObserver(([entry]) => {
|
||||||
observedWidth.value = Math.max(0, entry?.contentRect.width ?? 0)
|
observedWidth.value = Math.max(0, entry?.contentRect.width ?? 0)
|
||||||
@@ -1331,6 +1769,8 @@ onMounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', handleInteractionKeyDown)
|
||||||
|
window.removeEventListener('keyup', handleInteractionKeyUp)
|
||||||
cancelPendingHover()
|
cancelPendingHover()
|
||||||
resizeObserver.value?.disconnect()
|
resizeObserver.value?.disconnect()
|
||||||
clearZoomBindings()
|
clearZoomBindings()
|
||||||
@@ -1345,6 +1785,7 @@ onBeforeUnmount(() => {
|
|||||||
:class="[
|
:class="[
|
||||||
`waveform-chart--${displayMode}`,
|
`waveform-chart--${displayMode}`,
|
||||||
`waveform-chart--interaction-${activeInteractionMode}`,
|
`waveform-chart--interaction-${activeInteractionMode}`,
|
||||||
|
{ 'waveform-chart--panning': selection?.mode === 'pan' },
|
||||||
]"
|
]"
|
||||||
:style="containerStyle"
|
:style="containerStyle"
|
||||||
:data-display-mode="displayMode"
|
:data-display-mode="displayMode"
|
||||||
@@ -1388,6 +1829,7 @@ onBeforeUnmount(() => {
|
|||||||
:height="drawingHeight"
|
:height="drawingHeight"
|
||||||
role="img"
|
role="img"
|
||||||
:aria-label="hasWaveformData ? '波形折线图' : '暂无波形数据'"
|
:aria-label="hasWaveformData ? '波形折线图' : '暂无波形数据'"
|
||||||
|
@dblclick="requestViewportReset"
|
||||||
>
|
>
|
||||||
<defs>
|
<defs>
|
||||||
<clipPath
|
<clipPath
|
||||||
@@ -1426,6 +1868,9 @@ onBeforeUnmount(() => {
|
|||||||
:width="innerWidth"
|
:width="innerWidth"
|
||||||
:height="innerHeight"
|
:height="innerHeight"
|
||||||
@pointermove="handleSharedPointerMove"
|
@pointermove="handleSharedPointerMove"
|
||||||
|
@pointerdown="beginSharedViewportDrag"
|
||||||
|
@pointerup="finishViewportDrag"
|
||||||
|
@pointercancel="cancelViewportDrag"
|
||||||
@pointerleave="clearHover"
|
@pointerleave="clearHover"
|
||||||
@click="handleAnnotationClick"
|
@click="handleAnnotationClick"
|
||||||
@contextmenu="handleAnnotationContextMenu"
|
@contextmenu="handleAnnotationContextMenu"
|
||||||
@@ -1448,11 +1893,24 @@ onBeforeUnmount(() => {
|
|||||||
:y-label="yLabel"
|
:y-label="yLabel"
|
||||||
:hovered-point="hoveredSeriesPoints.find((p) => p.trackIndex === track.index)"
|
:hovered-point="hoveredSeriesPoints.find((p) => p.trackIndex === track.index)"
|
||||||
@pointer-move="handleIndependentPointerMove($event, track.index)"
|
@pointer-move="handleIndependentPointerMove($event, track.index)"
|
||||||
|
@pointer-down="beginViewportDrag($event, track.index, true)"
|
||||||
|
@pointer-up="finishViewportDrag"
|
||||||
|
@pointer-cancel="cancelViewportDrag"
|
||||||
@pointer-leave="clearHover"
|
@pointer-leave="clearHover"
|
||||||
@click="handleAnnotationClick($event, track.index)"
|
@click="handleAnnotationClick($event, track.index)"
|
||||||
@contextmenu="handleAnnotationContextMenu($event, track.index)"
|
@contextmenu="handleAnnotationContextMenu($event, track.index)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<rect
|
||||||
|
v-if="selectionBox && selection?.mode === 'box'"
|
||||||
|
class="waveform-chart__zoom-selection"
|
||||||
|
:x="selectionBox.x"
|
||||||
|
:y="selectionBox.y"
|
||||||
|
:width="selectionBox.width"
|
||||||
|
:height="selectionBox.height"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
|
||||||
<WaveformAnnotationLayer
|
<WaveformAnnotationLayer
|
||||||
:annotations="renderedAnnotations"
|
:annotations="renderedAnnotations"
|
||||||
:visible="annotationsVisible"
|
:visible="annotationsVisible"
|
||||||
@@ -1647,13 +2105,20 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.waveform-chart__overlay.is-zoomable {
|
.waveform-chart__overlay.is-zoomable {
|
||||||
cursor: grab;
|
cursor: crosshair;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waveform-chart__overlay.is-zoomable:active {
|
.waveform-chart--panning .waveform-chart__overlay.is-zoomable {
|
||||||
cursor: grabbing;
|
cursor: grabbing;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.waveform-chart__zoom-selection {
|
||||||
|
fill: rgb(22 119 255 / 14%);
|
||||||
|
stroke: #1677ff;
|
||||||
|
stroke-width: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.waveform-chart__overlay.is-annotating {
|
.waveform-chart__overlay.is-annotating {
|
||||||
cursor: crosshair;
|
cursor: crosshair;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ function layoutForSeries(
|
|||||||
overlayMode: 'single-axis',
|
overlayMode: 'single-axis',
|
||||||
independentTransforms: [transform],
|
independentTransforms: [transform],
|
||||||
sharedZoomDomain: sourceSeries.xDomain,
|
sharedZoomDomain: sourceSeries.xDomain,
|
||||||
|
yDomains: undefined,
|
||||||
timeUnit: 'ms',
|
timeUnit: 'ms',
|
||||||
rendering,
|
rendering,
|
||||||
hideSecondaryLabels: false,
|
hideSecondaryLabels: false,
|
||||||
@@ -75,6 +76,42 @@ function layoutForSeries(
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe('multi-value Y-axis grouping', () => {
|
describe('multi-value Y-axis grouping', () => {
|
||||||
|
it('uses a configured visible Y domain for axis and series scales', () => {
|
||||||
|
const source = series('a', 0, 100)
|
||||||
|
const sourceTrack = track([source])
|
||||||
|
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 },
|
||||||
|
displayMode: 'independent',
|
||||||
|
overlayMode: 'single-axis',
|
||||||
|
independentTransforms: [zoomIdentity],
|
||||||
|
sharedZoomDomain: [0, 1],
|
||||||
|
yDomains: { track: [25, 75] },
|
||||||
|
timeUnit: 'ms',
|
||||||
|
rendering: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||||
|
hideSecondaryLabels: false,
|
||||||
|
yAxisLabelX: -50,
|
||||||
|
showCompactEmptyTracks: false,
|
||||||
|
})[0]
|
||||||
|
|
||||||
|
expect(result?.yScale.domain()).toEqual([25, 75])
|
||||||
|
expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([25, 75])
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps every overlaid series on one axis in single-axis mode', () => {
|
it('keeps every overlaid series on one axis in single-axis mode', () => {
|
||||||
const groups = buildYAxisSeriesGroups(
|
const groups = buildYAxisSeriesGroups(
|
||||||
track([series('a', 0, 1), series('b', 10, 20)]),
|
track([series('a', 0, 1), series('b', 10, 20)]),
|
||||||
|
|||||||
@@ -190,6 +190,9 @@ export interface BuildTrackLayoutsOptions {
|
|||||||
overlayMode: WaveformOverlayMode
|
overlayMode: WaveformOverlayMode
|
||||||
independentTransforms: ZoomTransform[]
|
independentTransforms: ZoomTransform[]
|
||||||
sharedZoomDomain: [number, number]
|
sharedZoomDomain: [number, number]
|
||||||
|
initialXDomain?: [number, number]
|
||||||
|
initialXDomains?: Record<string, [number, number]>
|
||||||
|
yDomains?: Record<string, [number, number]>
|
||||||
timeUnit: 's' | 'ms'
|
timeUnit: 's' | 'ms'
|
||||||
rendering: ResolvedWaveformRenderingOptions
|
rendering: ResolvedWaveformRenderingOptions
|
||||||
hideSecondaryLabels: boolean
|
hideSecondaryLabels: boolean
|
||||||
@@ -226,14 +229,23 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|||||||
const series = displayTrack.visibleSeries[0] ?? displayTrack.series[0] ?? emptySeries
|
const series = displayTrack.visibleSeries[0] ?? displayTrack.series[0] ?? emptySeries
|
||||||
const baseXScale =
|
const baseXScale =
|
||||||
options.displayMode === 'independent'
|
options.displayMode === 'independent'
|
||||||
? scaleLinear(displayTrack.xDomain, [0, cell.width])
|
? scaleLinear(
|
||||||
|
options.initialXDomains?.[displayTrack.id] ??
|
||||||
|
options.initialXDomains?.[series.id] ??
|
||||||
|
displayTrack.xDomain,
|
||||||
|
[0, cell.width],
|
||||||
|
)
|
||||||
: scaleLinear(options.sharedZoomDomain, [0, cell.width])
|
: scaleLinear(options.sharedZoomDomain, [0, cell.width])
|
||||||
const transform =
|
const transform =
|
||||||
options.displayMode === 'independent'
|
options.displayMode === 'independent'
|
||||||
? (options.independentTransforms[index] ?? zoomIdentity)
|
? (options.independentTransforms[index] ?? zoomIdentity)
|
||||||
: zoomIdentity
|
: zoomIdentity
|
||||||
const xScale = transform.rescaleX(baseXScale)
|
const xScale = transform.rescaleX(baseXScale)
|
||||||
const yAxisGroups = buildYAxisSeriesGroups(displayTrack, options.overlayMode)
|
const configuredYDomain = options.yDomains?.[displayTrack.id]
|
||||||
|
const yAxisGroups = buildYAxisSeriesGroups(displayTrack, options.overlayMode).map((group) => ({
|
||||||
|
...group,
|
||||||
|
domain: configuredYDomain ?? group.domain,
|
||||||
|
}))
|
||||||
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]).nice()
|
||||||
|
|||||||
@@ -41,6 +41,9 @@ interface Props {
|
|||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'pointer-move', event: PointerEvent): void
|
(e: 'pointer-move', event: PointerEvent): void
|
||||||
|
(e: 'pointer-down', event: PointerEvent): void
|
||||||
|
(e: 'pointer-up', event: PointerEvent): void
|
||||||
|
(e: 'pointer-cancel', event: PointerEvent): void
|
||||||
(e: 'pointer-leave'): void
|
(e: 'pointer-leave'): void
|
||||||
(e: 'click', event: MouseEvent): void
|
(e: 'click', event: MouseEvent): void
|
||||||
(e: 'contextmenu', event: MouseEvent): void
|
(e: 'contextmenu', event: MouseEvent): void
|
||||||
@@ -409,6 +412,9 @@ watch(
|
|||||||
:width="track.width ?? innerWidth"
|
:width="track.width ?? innerWidth"
|
||||||
:height="track.height"
|
:height="track.height"
|
||||||
@pointermove="emit('pointer-move', $event)"
|
@pointermove="emit('pointer-move', $event)"
|
||||||
|
@pointerdown="emit('pointer-down', $event)"
|
||||||
|
@pointerup="emit('pointer-up', $event)"
|
||||||
|
@pointercancel="emit('pointer-cancel', $event)"
|
||||||
@pointerleave="emit('pointer-leave')"
|
@pointerleave="emit('pointer-leave')"
|
||||||
@click="emit('click', $event)"
|
@click="emit('click', $event)"
|
||||||
@contextmenu="emit('contextmenu', $event)"
|
@contextmenu="emit('contextmenu', $event)"
|
||||||
@@ -471,6 +477,8 @@ watch(
|
|||||||
fill: rgb(22 119 255 / 10%);
|
fill: rgb(22 119 255 / 10%);
|
||||||
font-family: Consolas, Monaco, 'Courier New', monospace;
|
font-family: Consolas, Monaco, 'Courier New', monospace;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waveform-track__overlay {
|
.waveform-track__overlay {
|
||||||
|
|||||||
@@ -30,8 +30,12 @@ export type WaveformInteractionMode = 'zoom' | 'annotation'
|
|||||||
export interface WaveformZoomEndPayload {
|
export interface WaveformZoomEndPayload {
|
||||||
start: number
|
start: number
|
||||||
end: number
|
end: number
|
||||||
|
yStart?: number
|
||||||
|
yEnd?: number
|
||||||
|
yRanges?: Record<string, [number, number]>
|
||||||
trackIndex?: number
|
trackIndex?: number
|
||||||
seriesIds?: string[]
|
seriesIds?: string[]
|
||||||
|
gesture?: 'wheel' | 'box'
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 标注颜色样式 */
|
/** 标注颜色样式 */
|
||||||
|
|||||||
Reference in New Issue
Block a user