Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d79185b952 | ||
|
|
4dd53aae11 | ||
|
|
9b42e3797b | ||
|
|
7646bf4907 | ||
|
|
e2725e5569 | ||
|
|
9eb1f0f137 | ||
|
|
fbf10fdb88 | ||
|
|
c5aa3c662a | ||
|
|
f98c264562 | ||
|
|
979b698156 | ||
|
|
a12002ba11 | ||
|
|
6cbb1e7668 | ||
|
|
f97ff24cdf |
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -19,6 +19,7 @@ jobs:
|
||||
- run: pnpm install --frozen-lockfile
|
||||
- run: pnpm typecheck
|
||||
- run: pnpm check:file-length
|
||||
- run: pnpm lint:oxlint
|
||||
- run: pnpm lint
|
||||
- run: pnpm test:coverage
|
||||
- run: pnpm build
|
||||
|
||||
8
.oxlintrc.json
Normal file
8
.oxlintrc.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"env": {
|
||||
"browser": true,
|
||||
"node": true
|
||||
},
|
||||
"ignorePatterns": ["dist/**", "dist-demo/**", "coverage/**", "node_modules/**"]
|
||||
}
|
||||
151
README.md
151
README.md
@@ -12,6 +12,9 @@
|
||||
|
||||
最新稳定版 Demo:<https://lqycustomsite.online/waveform-analysis/>
|
||||
|
||||
本地运行 `pnpm dev` 后,可通过
|
||||
<http://127.0.0.1:5173/#/fixed-y-domain> 查看固定振幅上下限示例。
|
||||
|
||||
## 特性
|
||||
|
||||
- Vue 3 Composition API + TypeScript,支持按需导入 `WaveformChart`
|
||||
@@ -22,6 +25,7 @@
|
||||
- 缩放过程事件、缩放结束按可视区间加载和视口重置
|
||||
- 可选的空格拖拽平移,默认关闭并隔离多图表实例
|
||||
- 多系列图例、受控显隐、网格分页和最多四根 Y 轴
|
||||
- 自动 Y 轴范围,以及全局、按轨道或按系列配置的固定振幅范围
|
||||
- 按轨道控制水平/垂直网格线的显隐与颜色
|
||||
- 受控标注、右键编辑、拖拽避让和自定义颜色
|
||||
- 标题、图框、坐标轴、零值参考线、净图和渲染参数可配置
|
||||
@@ -92,38 +96,41 @@ const data = ref<WaveformData>({
|
||||
|
||||
### Props
|
||||
|
||||
| Prop | 类型 | 默认值 | 说明 |
|
||||
| --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | --------------------------------- |
|
||||
| `data` | `WaveformData` | 必填 | 波形数据 |
|
||||
| `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 |
|
||||
| `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 |
|
||||
| `timeUnit` | `'s' \| 'ms'` | `'ms'` | 坐标轴和 tooltip 展示单位 |
|
||||
| `xLabel` / `yLabel` | `string` | `时间(timeUnit)` / `'幅值'` | 坐标轴名称 |
|
||||
| `lineColor` | `string` | `'#0960bd'` | 单波形默认颜色 |
|
||||
| `width` / `height` | `number` | 自适应 | 组件总尺寸,单位为 CSS 像素 |
|
||||
| `zoomable` / `showTooltip` | `boolean` | `true` / `true` | 缩放和数值 tooltip 开关 |
|
||||
| `pannable` | `boolean` | `false` | 空格拖拽平移开关 |
|
||||
| `minZoomSpan` | `number` | 未设置 | 最小缩放跨度,使用原始 X 数据单位 |
|
||||
| `minVisiblePoints` | `number` | `0` | 缩放后至少保留的不同 X 坐标数 |
|
||||
| `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围 |
|
||||
| `initialXDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置初始范围 |
|
||||
| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 |
|
||||
| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐 |
|
||||
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
|
||||
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
|
||||
| `frameNumber` | `string \| number` | 未设置 | 图框水印内容 |
|
||||
| `zeroLine` | `WaveformZeroLineOptions` | `{ visible: false }` | 零值参考线显隐与样式 |
|
||||
| `cleanView` | `boolean` | `false` | 仅保留波形的净图模式 |
|
||||
| `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 |
|
||||
| `annotationsVisible` | `boolean` | `true` | 标注图层显隐 |
|
||||
| `interactionMode` | `'zoom' \| 'annotation'` | `'zoom'` | 左键交互模式 |
|
||||
| `hiddenSeriesIds` | `string[]` | 未设置 | 受控隐藏系列 ID |
|
||||
| `defaultHiddenSeriesIds` | `string[]` | `[]` | 非受控模式的初始隐藏系列 |
|
||||
| Prop | 类型 | 默认值 | 说明 |
|
||||
| --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | ---------------------------------- |
|
||||
| `data` | `WaveformData` | 必填 | 波形数据 |
|
||||
| `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 |
|
||||
| `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 |
|
||||
| `timeUnit` | `'s' \| 'ms'` | `'ms'` | 坐标轴和 tooltip 展示单位 |
|
||||
| `xLabel` / `yLabel` | `string` | `时间(timeUnit)` / `'幅值'` | 坐标轴名称 |
|
||||
| `lineColor` | `string` | `'#0960bd'` | 单波形默认颜色 |
|
||||
| `width` / `height` | `number` | 自适应 | 组件总尺寸,单位为 CSS 像素 |
|
||||
| `zoomable` / `showTooltip` | `boolean` | `true` / `true` | 缩放和数值 tooltip 开关 |
|
||||
| `pannable` | `boolean` | `false` | 空格拖拽平移开关 |
|
||||
| `minZoomSpan` | `number` | 未设置 | 最小缩放跨度,使用原始 X 数据单位 |
|
||||
| `minVisiblePoints` | `number` | `0` | 缩放后至少保留的不同 X 坐标数 |
|
||||
| `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围 |
|
||||
| `initialXDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置初始范围 |
|
||||
| `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 |
|
||||
| `yDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置固定范围 |
|
||||
| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 |
|
||||
| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐与 X 轴 label 格式化 |
|
||||
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
|
||||
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
|
||||
| `frameNumber` | `string \| number` | 未设置 | 图框水印内容 |
|
||||
| `zeroLine` | `WaveformZeroLineOptions` | `{ visible: false }` | 零值参考线显隐与样式 |
|
||||
| `cleanView` | `boolean` | `false` | 保留波形、图框和刻度的净图模式 |
|
||||
| `presentationMode` | `boolean` | `false` | 禁用绘图区交互的展示模式 |
|
||||
| `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 |
|
||||
| `annotationsVisible` | `boolean` | `true` | 标注图层显隐 |
|
||||
| `interactionMode` | `'zoom' \| 'annotation'` | `'zoom'` | 左键交互模式 |
|
||||
| `hiddenSeriesIds` | `string[]` | 未设置 | 受控隐藏系列 ID |
|
||||
| `defaultHiddenSeriesIds` | `string[]` | `[]` | 非受控模式的初始隐藏系列 |
|
||||
|
||||
所有公开类型均可从包入口导入,例如 `WaveformData`、`WaveformSeries`、
|
||||
`WaveformAnnotation`、`WaveformLineStyle`、`WaveformRenderingOptions`、
|
||||
`WaveformAxesOptions`、`WaveformZeroLineOptions`、`WaveformGridOptions` 和
|
||||
`WaveformGridTrackLines`。
|
||||
`WaveformAxesOptions`、`WaveformXAxisLabelFormatter`、`WaveformZeroLineOptions`、
|
||||
`WaveformGridOptions` 和 `WaveformGridTrackLines`。
|
||||
|
||||
### 数据结构
|
||||
|
||||
@@ -176,11 +183,43 @@ import 'waveform-analysis/style.css'
|
||||
</template>
|
||||
```
|
||||
|
||||
### 固定振幅上下限
|
||||
|
||||
未传入 Y 轴范围时,组件继续根据当前可见系列及其误差棒自动计算范围。传入 `yDomain`
|
||||
后,所有波形使用同一个固定范围;超出范围的部分只在绘图区裁剪,不会过滤或修改原始数据:
|
||||
|
||||
```vue
|
||||
<WaveformChart :data="chartData" :y-domain="[-80, 80]" />
|
||||
```
|
||||
|
||||
多通道可以通过 `yDomains` 按稳定的 `trackId` 或 `seriesId` 分别配置:
|
||||
|
||||
```vue
|
||||
<WaveformChart
|
||||
:data="chartData"
|
||||
:y-domain="[-100, 100]"
|
||||
:y-domains="{
|
||||
voltage: [-65, 65],
|
||||
current: [-260, 260],
|
||||
}"
|
||||
/>
|
||||
```
|
||||
|
||||
范围优先级为 `trackId` 配置、`seriesId` 配置、全局 `yDomain`、数据自动范围。上下限必须
|
||||
是两个有限且不相等的数字;倒序范围会自动调整为升序,无效配置会回退到下一优先级。
|
||||
固定范围会精确作为坐标域使用,不会经过 D3 的 `nice()` 扩展。
|
||||
|
||||
单值轴叠加模式会合并同一根轴上所有可见系列的有效范围;多值轴模式按系列分别使用配置,
|
||||
超过四根轴后复用第 4 根轴的系列会取范围并集。隐藏系列不参与公共范围合并。
|
||||
|
||||
固定范围存在时,对应图框的 Y 轴不会被平移或视口重置覆盖;X 轴缩放、平移和重置保持原有
|
||||
行为。运行时更新或移除 `yDomain` / `yDomains` 会立即重新布局,移除后恢复自动范围。
|
||||
|
||||
### 缩放后按可视区间加载数据
|
||||
|
||||
组件支持 Plotly 风格的矩形框选缩放:在 zoom 模式下按住鼠标左键拖拽,松开后同时缩放
|
||||
X/Y 轴;设置 `pannable` 后,指针位于图表内时按住空格键拖拽可平移当前视口。
|
||||
鼠标滚轮仍可放大,双击恢复完整视口。
|
||||
鼠标滚轮可放大和缩小,双击恢复完整视口。
|
||||
组件会在滚轮或框选缩放结束后触发 `zoom-end`,调用方可以使用端点请求后端,再通过
|
||||
`data` 传回新数据。独立分图模式还会包含 `trackIndex` 和稳定的 `seriesIds`。
|
||||
|
||||
@@ -410,9 +449,10 @@ scale 定位零线:
|
||||
`dash` 直接对应 SVG 的 `stroke-dasharray`;传入空字符串可显示实线。无效或非正数的
|
||||
`width` 会回退到 `1`。
|
||||
|
||||
设置 `cleanView` 后,组件隐藏标题内容、图例、网格、坐标轴、轴标签、图框背景与边框、帧水印、
|
||||
零值参考线、标注和分页器,同时保留原图的标题区域、边距和波形尺寸。缩放、悬浮、十字线和
|
||||
tooltip 仍然可用,切换回普通模式后原有配置和标注不会丢失:
|
||||
设置 `cleanView` 后,组件保留波形、图框边框、X/Y 轴刻度及刻度值,并隐藏标题内容、图例、
|
||||
网格、轴标签、图框背景、帧水印、零值参考线、标注和分页器。原图的标题区域、边距和波形
|
||||
尺寸保持不变;缩放、悬浮、十字线和 tooltip 仍然可用,切换回普通模式后原有配置和标注
|
||||
不会丢失:
|
||||
|
||||
```vue
|
||||
<WaveformChart :data="chartData" :clean-view="cleanViewEnabled" />
|
||||
@@ -468,9 +508,41 @@ tooltip 仍然可用,切换回普通模式后原有配置和标注不会丢失
|
||||
/>
|
||||
```
|
||||
|
||||
X 轴刻度和左右端点默认先按 `timeUnit` 转换为秒或毫秒,再显示为无千分位、无科学计数法的
|
||||
完整普通十进制值,不会四舍五入为整数。可通过 `axes.x.labelFormatter` 对显示值做运算和格式化:
|
||||
|
||||
```vue
|
||||
<WaveformChart
|
||||
:data="chartData"
|
||||
:axes="{
|
||||
x: {
|
||||
labelFormatter: (value, context) =>
|
||||
`${context.kind === 'tick' ? '' : '[' + context.kind + '] '}${(value / 1000).toFixed(3)}`,
|
||||
},
|
||||
}"
|
||||
/>
|
||||
```
|
||||
|
||||
formatter 首参是已按 `timeUnit` 换算的数值;上下文包含 `kind`(`tick`、`start` 或 `end`)、
|
||||
原始 `rawValue`、`timeUnit`、原始可视域 `domain` 和换算后的 `displayDomain`。formatter 只决定
|
||||
label 文本,不改变刻度位置、源数据、缩放域或事件中的原始 X 坐标。
|
||||
|
||||
Demo 左侧“网格与轴线”支持直接切换数值或固定时间格式。数值模式可设置运算倍率和小数位数;
|
||||
固定时间模式输出 `YYYY-MM-DD HH:mm:ss[.SSS]`,可选择中国标准时间、本地时区或 UTC,并控制
|
||||
是否显示毫秒。时间戳数据仍按组件的秒坐标契约传入,使用默认毫秒显示单位时 formatter 会收到
|
||||
可直接传给 `Date` 的毫秒时间戳。
|
||||
|
||||
`interactionMode` 可选 `zoom` 或 `annotation`,默认使用缩放模式。右键绘图区可直接打开
|
||||
标注编辑器,无需切换交互模式。`zoomable`、`pannable` 和 `showTooltip` 可分别控制缩放、
|
||||
空格拖拽平移和 tooltip;平移默认关闭。
|
||||
|
||||
展示场景可启用 `presentationMode`,统一禁用绘图区的 tooltip、缩放、平移、双击复位和
|
||||
标注交互。该模式不会隐藏任何图形内容,也不会禁用图例切换或分页;关闭后恢复原交互配置。
|
||||
|
||||
```vue
|
||||
<WaveformChart :data="chartData" :presentation-mode="true" />
|
||||
```
|
||||
|
||||
空数据或过滤后没有有效点时,组件会保留图框布局并显示“暂无有效波形数据”。
|
||||
|
||||
## 大数据渲染
|
||||
@@ -555,7 +627,7 @@ async function importAnnotationFile(file: File) {
|
||||
字段无效时会抛出 `TypeError`,不会返回部分结果。导入包含未知 `seriesId` 的标注是允许的,
|
||||
对应曲线加载后会恢复显示。文件选择、错误提示和下载由业务层实现。
|
||||
|
||||
X、Y 轴会根据各自完整显示域选择格式:最大绝对值在 `[0.01, 100)` 时显示两位普通小数;大于等于 `100`,或大于 `0` 且小于 `0.01` 时,刻度显示两位缩放值,并在轴末端单独显示共享倍率 `E±NN`。X 轴先按 `timeUnit` 转换为秒或毫秒再判断范围,多 Y 轴则分别计算倍率。tooltip 使用最多 4 位小数的本地化普通数字;标注编辑器的 X 坐标跟随 `timeUnit` 并固定 3 位小数,Y 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。
|
||||
X 轴刻度和左右端点先按 `timeUnit` 转换为秒或毫秒,再显示为不带分组符和科学计数法的完整普通十进制值,并可通过 `axes.x.labelFormatter` 自定义。Y 轴会根据完整显示域选择格式:最大绝对值在 `[0.01, 100)` 时显示两位普通小数;大于等于 `100`,或大于 `0` 且小于 `0.01` 时,刻度显示两位缩放值,并在轴末端单独显示共享倍率 `E±NN`,多 Y 轴分别计算倍率。tooltip 使用最多 4 位小数的本地化普通数字并省略无意义尾零;标注编辑器的 X 坐标跟随 `timeUnit` 并固定 3 位小数,Y 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。
|
||||
标注框默认布局在采样点正上方,只做绘图区边界裁剪;文本框通过连接箭头指向标注位置,多个标注重叠时可通过拖动手动避让。
|
||||
|
||||
## 事件
|
||||
@@ -580,7 +652,8 @@ X、Y 轴会根据各自完整显示域选择格式:最大绝对值在 `[0.01,
|
||||
- `src/index.ts`:组件库公开入口和工具函数导出
|
||||
- `src/components/WaveformChart.vue`:图表容器、缩放、tooltip、图例和标注编排
|
||||
- `src/components/{core,data,rendering,interaction,annotation}`:数据、布局、渲染和交互模块
|
||||
- `src/App.vue`:可交互 demo,`src/data` 中提供示例波形数据
|
||||
- `src/App.vue`:综合可交互 demo,`src/data` 中提供示例波形数据
|
||||
- `src/router.ts`:Demo 路由;`src/views/FixedYDomainDemo.vue` 为固定振幅范围示例
|
||||
|
||||
## 本地开发
|
||||
|
||||
@@ -595,12 +668,20 @@ pnpm dev
|
||||
|
||||
```bash
|
||||
pnpm typecheck
|
||||
pnpm lint:oxlint
|
||||
pnpm lint
|
||||
pnpm lint:all
|
||||
pnpm format:check
|
||||
pnpm test
|
||||
pnpm test:coverage
|
||||
pnpm build
|
||||
```
|
||||
|
||||
`pnpm lint:oxlint` 使用 Oxlint 的默认 correctness 检查及内置 TypeScript、Unicorn 和 Oxc
|
||||
插件,自动忽略 `dist/`、`dist-demo/`、`coverage/` 和 `node_modules/`。`pnpm lint` 继续负责
|
||||
ESLint 的 Vue SFC、TypeScript ESLint 和 `max-lines` 规则;`pnpm lint:all` 会依次运行两者。
|
||||
`pnpm format:check` 只读检查 Prettier 格式,`pnpm format` 保持原有的写入行为。
|
||||
|
||||
`pnpm build` 同时生成 `dist/` 组件库产物和 `dist-demo/` 演示应用。正式公开入口为
|
||||
`src/index.ts`,样式入口为 `src/styles.css`;`dist/` 和 `dist-demo/` 均为生成目录,不要手工编辑。
|
||||
|
||||
|
||||
37
docs/architecture.md
Normal file
37
docs/architecture.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Architecture
|
||||
|
||||
Waveform Analysis uses a functional architecture around Vue Composition API. Vue composables own
|
||||
reactive orchestration and DOM resources, while pure functions own stateless calculations and
|
||||
state transitions.
|
||||
|
||||
## Boundaries
|
||||
|
||||
- `useWaveformChartController` is the facade for the chart. It composes composables and exposes the
|
||||
existing reactive controller surface; it owns no independent copy of component props.
|
||||
- `useWaveformViewport` keeps refs, computed values, pointer events, DOM capture, D3 coordinates,
|
||||
and emitted events. `transitionViewportInteraction` and `reduceViewportInteraction` are pure
|
||||
reducer functions for the legal `begin`, `move`, `finish`, `cancel`, and `reset` transitions.
|
||||
The composable's `selection` shallow ref is the only interaction state source; SVG overlays and
|
||||
pointer capture remain DOM resources local to the composable.
|
||||
- `RenderablePointSelectionStrategy` is a function type defining the replaceable rendering
|
||||
algorithm boundary. `completePointSelectionStrategy` preserves the complete visible source range
|
||||
and `peakPreservingPointSelectionStrategy` preserves first/minimum/maximum/last points per
|
||||
bucket. `resolveRenderablePointSelectionStrategy` selects the function from rendering options.
|
||||
The existing `selectRenderablePoints` function remains the compatibility facade used by rendering.
|
||||
- `normalizeWaveformData` and `normalizeWaveformSeries` are functional adapters from public data
|
||||
shapes to the internal series model. `buildTrackLayouts` remains a functional builder because
|
||||
layout construction is a stateless calculation, not a long-lived object.
|
||||
|
||||
## Vue Integration
|
||||
|
||||
The viewport `selection` is stored in a shallow ref and updated only with reducer transitions. SVG
|
||||
overlay elements remain in the composable as DOM resources. Presentation mode, annotation editing,
|
||||
scales, domains, ticks, formatting, and other stateless calculations stay in their existing
|
||||
computed/composable or function boundaries.
|
||||
|
||||
## Constraints
|
||||
|
||||
Do not create classes solely to wrap Composition API refs, props, lifecycle hooks, D3 selections, or
|
||||
pure mathematical helpers. Do not add strategy factories, inheritance trees, global event buses,
|
||||
service locators, or duplicate prop state. Keep pure algorithms and reducer transitions as
|
||||
side-effect-free functions with isolated tests.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "waveform-analysis",
|
||||
"version": "0.1.22",
|
||||
"version": "0.1.31",
|
||||
"main": "./dist/index.cjs",
|
||||
"module": "./dist/index.js",
|
||||
"types": "./dist/types/index.d.ts",
|
||||
@@ -14,6 +14,7 @@
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"src",
|
||||
"README.md"
|
||||
],
|
||||
"sideEffects": [
|
||||
@@ -30,13 +31,17 @@
|
||||
"typecheck": "vue-tsc -b",
|
||||
"check:file-length": "node scripts/check-file-length.mjs",
|
||||
"lint": "eslint . --max-warnings=0",
|
||||
"lint:oxlint": "oxlint . --deny-warnings",
|
||||
"lint:all": "pnpm lint:oxlint && pnpm lint",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"test": "vitest run",
|
||||
"test:coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"ant-design-vue": "3.2.20",
|
||||
"d3": "^7.9.0",
|
||||
"vue-router": "^4.6.4",
|
||||
"vue3-colorpicker": "^2.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
@@ -55,6 +60,7 @@
|
||||
"eslint-plugin-vue": "10.9.2",
|
||||
"globals": "17.7.0",
|
||||
"jsdom": "29.1.1",
|
||||
"oxlint": "1.77.0",
|
||||
"prettier": "3.9.5",
|
||||
"typescript": "~6.0.0",
|
||||
"typescript-eslint": "8.64.0",
|
||||
|
||||
235
pnpm-lock.yaml
generated
235
pnpm-lock.yaml
generated
@@ -14,6 +14,9 @@ importers:
|
||||
d3:
|
||||
specifier: ^7.9.0
|
||||
version: 7.9.0
|
||||
vue-router:
|
||||
specifier: ^4.6.4
|
||||
version: 4.6.4(vue@3.5.40(typescript@6.0.3))
|
||||
vue3-colorpicker:
|
||||
specifier: ^2.3.0
|
||||
version: 2.3.0(@aesoper/normal-utils@0.1.5)(@popperjs/core@2.11.8)(@vueuse/core@10.11.1(vue@3.5.40(typescript@6.0.3)))(gradient-parser@1.2.0)(lodash-es@4.18.1)(tinycolor2@1.6.0)(vue-types@4.2.1(vue@3.5.40(typescript@6.0.3)))(vue@3.5.40(typescript@6.0.3))
|
||||
@@ -51,6 +54,9 @@ importers:
|
||||
jsdom:
|
||||
specifier: 29.1.1
|
||||
version: 29.1.1
|
||||
oxlint:
|
||||
specifier: 1.77.0
|
||||
version: 1.77.0
|
||||
prettier:
|
||||
specifier: 3.9.5
|
||||
version: 3.9.5
|
||||
@@ -276,6 +282,128 @@ packages:
|
||||
'@oxc-project/types@0.139.0':
|
||||
resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==}
|
||||
|
||||
'@oxlint/binding-android-arm-eabi@1.77.0':
|
||||
resolution: {integrity: sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [android]
|
||||
|
||||
'@oxlint/binding-android-arm64@1.77.0':
|
||||
resolution: {integrity: sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@oxlint/binding-darwin-arm64@1.77.0':
|
||||
resolution: {integrity: sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint/binding-darwin-x64@1.77.0':
|
||||
resolution: {integrity: sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@oxlint/binding-freebsd-x64@1.77.0':
|
||||
resolution: {integrity: sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.77.0':
|
||||
resolution: {integrity: sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.77.0':
|
||||
resolution: {integrity: sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@oxlint/binding-linux-arm64-gnu@1.77.0':
|
||||
resolution: {integrity: sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.77.0':
|
||||
resolution: {integrity: sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.77.0':
|
||||
resolution: {integrity: sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.77.0':
|
||||
resolution: {integrity: sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.77.0':
|
||||
resolution: {integrity: sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.77.0':
|
||||
resolution: {integrity: sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.77.0':
|
||||
resolution: {integrity: sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [glibc]
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.77.0':
|
||||
resolution: {integrity: sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
libc: [musl]
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.77.0':
|
||||
resolution: {integrity: sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [openharmony]
|
||||
|
||||
'@oxlint/binding-win32-arm64-msvc@1.77.0':
|
||||
resolution: {integrity: sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/binding-win32-ia32-msvc@1.77.0':
|
||||
resolution: {integrity: sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [ia32]
|
||||
os: [win32]
|
||||
|
||||
'@oxlint/binding-win32-x64-msvc@1.77.0':
|
||||
resolution: {integrity: sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||
engines: {node: '>=14'}
|
||||
@@ -632,6 +760,9 @@ packages:
|
||||
'@vue/compiler-ssr@3.5.40':
|
||||
resolution: {integrity: sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==}
|
||||
|
||||
'@vue/devtools-api@6.6.4':
|
||||
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
|
||||
|
||||
'@vue/language-core@3.3.7':
|
||||
resolution: {integrity: sha512-LzmkKinXAMMoh8Jfi/jMUSDUjuPdv8mynH5WJGKfXyZtDw3hQ6GBaoI6Bcnl/Xqlu32q/0Z6i/trp4VXykzyLw==}
|
||||
|
||||
@@ -1379,6 +1510,19 @@ packages:
|
||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
|
||||
oxlint@1.77.0:
|
||||
resolution: {integrity: sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg==}
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
oxlint-tsgolint: '>=7.0.2001'
|
||||
vite-plus: '*'
|
||||
peerDependenciesMeta:
|
||||
oxlint-tsgolint:
|
||||
optional: true
|
||||
vite-plus:
|
||||
optional: true
|
||||
|
||||
p-limit@3.1.0:
|
||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1704,6 +1848,11 @@ packages:
|
||||
peerDependencies:
|
||||
eslint: ^8.57.0 || ^9.0.0 || ^10.0.0
|
||||
|
||||
vue-router@4.6.4:
|
||||
resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==}
|
||||
peerDependencies:
|
||||
vue: ^3.5.0
|
||||
|
||||
vue-tsc@3.3.7:
|
||||
resolution: {integrity: sha512-+C+rgD49wAQ5bUTl2sp5a8Bzg4YoldMNXM+g7CFe604MYcQ8PrZPMQhIjJSzKXtPBCa+C5ayMipqjbA7splekQ==}
|
||||
hasBin: true
|
||||
@@ -1978,6 +2127,63 @@ snapshots:
|
||||
|
||||
'@oxc-project/types@0.139.0': {}
|
||||
|
||||
'@oxlint/binding-android-arm-eabi@1.77.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-android-arm64@1.77.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-darwin-arm64@1.77.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-darwin-x64@1.77.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-freebsd-x64@1.77.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm-gnueabihf@1.77.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm-musleabihf@1.77.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm64-gnu@1.77.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-arm64-musl@1.77.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-ppc64-gnu@1.77.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-riscv64-gnu@1.77.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-riscv64-musl@1.77.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-s390x-gnu@1.77.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-x64-gnu@1.77.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-linux-x64-musl@1.77.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-openharmony-arm64@1.77.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-win32-arm64-msvc@1.77.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-win32-ia32-msvc@1.77.0':
|
||||
optional: true
|
||||
|
||||
'@oxlint/binding-win32-x64-msvc@1.77.0':
|
||||
optional: true
|
||||
|
||||
'@pkgjs/parseargs@0.11.0':
|
||||
optional: true
|
||||
|
||||
@@ -2378,6 +2584,8 @@ snapshots:
|
||||
'@vue/compiler-dom': 3.5.40
|
||||
'@vue/shared': 3.5.40
|
||||
|
||||
'@vue/devtools-api@6.6.4': {}
|
||||
|
||||
'@vue/language-core@3.3.7':
|
||||
dependencies:
|
||||
'@volar/language-core': 2.4.28
|
||||
@@ -3130,6 +3338,28 @@ snapshots:
|
||||
type-check: 0.4.0
|
||||
word-wrap: 1.2.5
|
||||
|
||||
oxlint@1.77.0:
|
||||
optionalDependencies:
|
||||
'@oxlint/binding-android-arm-eabi': 1.77.0
|
||||
'@oxlint/binding-android-arm64': 1.77.0
|
||||
'@oxlint/binding-darwin-arm64': 1.77.0
|
||||
'@oxlint/binding-darwin-x64': 1.77.0
|
||||
'@oxlint/binding-freebsd-x64': 1.77.0
|
||||
'@oxlint/binding-linux-arm-gnueabihf': 1.77.0
|
||||
'@oxlint/binding-linux-arm-musleabihf': 1.77.0
|
||||
'@oxlint/binding-linux-arm64-gnu': 1.77.0
|
||||
'@oxlint/binding-linux-arm64-musl': 1.77.0
|
||||
'@oxlint/binding-linux-ppc64-gnu': 1.77.0
|
||||
'@oxlint/binding-linux-riscv64-gnu': 1.77.0
|
||||
'@oxlint/binding-linux-riscv64-musl': 1.77.0
|
||||
'@oxlint/binding-linux-s390x-gnu': 1.77.0
|
||||
'@oxlint/binding-linux-x64-gnu': 1.77.0
|
||||
'@oxlint/binding-linux-x64-musl': 1.77.0
|
||||
'@oxlint/binding-openharmony-arm64': 1.77.0
|
||||
'@oxlint/binding-win32-arm64-msvc': 1.77.0
|
||||
'@oxlint/binding-win32-ia32-msvc': 1.77.0
|
||||
'@oxlint/binding-win32-x64-msvc': 1.77.0
|
||||
|
||||
p-limit@3.1.0:
|
||||
dependencies:
|
||||
yocto-queue: 0.1.0
|
||||
@@ -3386,6 +3616,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
vue-router@4.6.4(vue@3.5.40(typescript@6.0.3)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 6.6.4
|
||||
vue: 3.5.40(typescript@6.0.3)
|
||||
|
||||
vue-tsc@3.3.7(typescript@6.0.3):
|
||||
dependencies:
|
||||
'@volar/typescript': 2.4.28
|
||||
|
||||
@@ -92,19 +92,22 @@ describe('App workspace layout', { timeout: 20_000 }, () => {
|
||||
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)
|
||||
await flushPromises()
|
||||
const chart = wrapper.getComponent(WaveformChart)
|
||||
|
||||
expect(chart.props('cleanView')).toBe(false)
|
||||
expect(chart.props('presentationMode')).toBe(false)
|
||||
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 flushPromises()
|
||||
|
||||
expect(chart.props('cleanView')).toBe(true)
|
||||
expect(chart.props('presentationMode')).toBe(true)
|
||||
expect(chart.props('zeroLine')).toMatchObject({ visible: true, color: '#98a2b3', width: 1 })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
25
src/App.vue
25
src/App.vue
@@ -14,6 +14,7 @@ import {
|
||||
type WaveformLegendOrientation,
|
||||
type WaveformLegendPosition,
|
||||
type WaveformOverlayMode,
|
||||
type WaveformPlotMargin,
|
||||
type WaveformTitleOptions,
|
||||
type WaveformZoomEndPayload,
|
||||
type WaveformZeroLineOptions,
|
||||
@@ -23,6 +24,7 @@ import { createSimulatedWaveformData } from './data/simulatedWaveforms'
|
||||
import DemoChartHost from './demo/DemoChartHost.vue'
|
||||
import DemoControlPanel from './demo/DemoControlPanel.vue'
|
||||
import type { DemoChartModel, DemoControlPanelModel } from './demo/types'
|
||||
import { useDemoXAxisLabelControls } from './demo/useDemoXAxisLabelControls'
|
||||
|
||||
const fullChartData = createSimulatedWaveformData()
|
||||
const displayMode = ref<WaveformDisplayMode>('independent')
|
||||
@@ -40,10 +42,14 @@ const verticalGridVisible = ref(true)
|
||||
const verticalGridColor = ref('#dfe5ef')
|
||||
const xAxisLineVisible = ref(false)
|
||||
const yAxisLineVisible = ref(false)
|
||||
const { controlModel: xAxisLabelControlModel, xAxisLabelFormatter } = useDemoXAxisLabelControls()
|
||||
const annotations = ref<WaveformAnnotation[]>([])
|
||||
const annotationsVisible = ref(true)
|
||||
const cleanView = ref(false)
|
||||
const presentationMode = ref(false)
|
||||
const showTooltip = ref(true)
|
||||
const plotMarginTop = ref(18)
|
||||
const plotMarginBottom = ref(52)
|
||||
const zeroLineVisible = ref(false)
|
||||
const zeroLineColor = ref('#98a2b3')
|
||||
const zeroLineWidth = ref(1)
|
||||
@@ -111,7 +117,10 @@ const frameStyle = computed<WaveformFrameStyle>(() => ({
|
||||
backgroundColor: frameBackgroundColor.value,
|
||||
}))
|
||||
const axes = computed<WaveformAxesOptions>(() => ({
|
||||
x: { lineVisible: xAxisLineVisible.value },
|
||||
x: {
|
||||
lineVisible: xAxisLineVisible.value,
|
||||
...(xAxisLabelFormatter.value ? { labelFormatter: xAxisLabelFormatter.value } : {}),
|
||||
},
|
||||
y: { lineVisible: yAxisLineVisible.value },
|
||||
}))
|
||||
const zeroLine = computed<WaveformZeroLineOptions>(() => ({
|
||||
@@ -120,6 +129,10 @@ const zeroLine = computed<WaveformZeroLineOptions>(() => ({
|
||||
width: zeroLineWidth.value,
|
||||
dash: zeroLineDash.value,
|
||||
}))
|
||||
const plotMargin = computed<WaveformPlotMargin>(() => ({
|
||||
top: plotMarginTop.value,
|
||||
bottom: plotMarginBottom.value,
|
||||
}))
|
||||
|
||||
const initialXValues = normalizeWaveformSeries(fullChartData).flatMap((series) =>
|
||||
series.points.map((point) => point.x),
|
||||
@@ -128,9 +141,6 @@ 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]
|
||||
@@ -288,7 +298,10 @@ const controlPanelModel = reactive({
|
||||
displayMode,
|
||||
overlayMode,
|
||||
showTooltip,
|
||||
plotMarginTop,
|
||||
plotMarginBottom,
|
||||
cleanView,
|
||||
presentationMode,
|
||||
selectedSeriesId,
|
||||
selectedLineStyle,
|
||||
lineStyleOptions,
|
||||
@@ -306,6 +319,7 @@ const controlPanelModel = reactive({
|
||||
verticalGridColor,
|
||||
xAxisLineVisible,
|
||||
yAxisLineVisible,
|
||||
...xAxisLabelControlModel,
|
||||
frameBorderColor,
|
||||
frameBackgroundColor,
|
||||
frameBorderWidth,
|
||||
@@ -336,7 +350,6 @@ const controlPanelModel = reactive({
|
||||
|
||||
const chartModel = reactive({
|
||||
data: displayChartData,
|
||||
minZoomSpan,
|
||||
initialXDomain,
|
||||
displayMode,
|
||||
overlayMode,
|
||||
@@ -350,7 +363,9 @@ const chartModel = reactive({
|
||||
frameStyle,
|
||||
axes,
|
||||
cleanView,
|
||||
presentationMode,
|
||||
showTooltip,
|
||||
plotMargin,
|
||||
zeroLine,
|
||||
frameWatermarkVisible,
|
||||
annotations,
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
.waveform-chart--presentation .waveform-chart__overlay {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.waveform-chart__label,
|
||||
.waveform-chart__empty {
|
||||
fill: #595959;
|
||||
|
||||
@@ -23,9 +23,11 @@ const props = withDefaults(defineProps<WaveformChartProps>(), {
|
||||
interactionMode: undefined,
|
||||
grid: () => ({ rowCount: 2, columnCount: 1, showPagination: true }),
|
||||
rendering: () => ({}),
|
||||
plotMargin: () => ({}),
|
||||
legend: () => ({ position: 'top-right', orientation: 'auto' }),
|
||||
defaultHiddenSeriesIds: () => [],
|
||||
cleanView: false,
|
||||
presentationMode: false,
|
||||
zeroLine: () => ({ visible: false }),
|
||||
})
|
||||
const emit = defineEmits<WaveformChartEmit>()
|
||||
|
||||
@@ -13,11 +13,13 @@ const {
|
||||
displayMode,
|
||||
activeInteractionMode,
|
||||
isCleanView,
|
||||
isPresentationMode,
|
||||
selection,
|
||||
containerStyle,
|
||||
overlayMode,
|
||||
resolvedChartLeftMargin,
|
||||
titleAreaHeight,
|
||||
resolvedPlotMargin,
|
||||
pointerInsideChart,
|
||||
handleNativeContextMenu,
|
||||
titleAreaReserved,
|
||||
@@ -45,7 +47,7 @@ const {
|
||||
beginSharedViewportDrag,
|
||||
finishViewportDrag,
|
||||
cancelViewportDrag,
|
||||
clearHover,
|
||||
handlePointerLeave,
|
||||
handleAnnotationClick,
|
||||
handleAnnotationContextMenu,
|
||||
resolveFrameNumber,
|
||||
@@ -107,14 +109,18 @@ const {
|
||||
`waveform-chart--interaction-${activeInteractionMode}`,
|
||||
{
|
||||
'waveform-chart--clean': isCleanView,
|
||||
'waveform-chart--panning': selection?.mode === 'pan',
|
||||
'waveform-chart--presentation': isPresentationMode,
|
||||
'waveform-chart--panning': selection?.kind === 'pan',
|
||||
},
|
||||
]"
|
||||
:style="containerStyle"
|
||||
:data-display-mode="displayMode"
|
||||
:data-interaction-mode="activeInteractionMode"
|
||||
:data-presentation-mode="isPresentationMode"
|
||||
:data-overlay-mode="overlayMode"
|
||||
:data-chart-left-margin="resolvedChartLeftMargin"
|
||||
:data-plot-margin-top="resolvedPlotMargin.top"
|
||||
:data-plot-margin-bottom="resolvedPlotMargin.bottom"
|
||||
:data-title-area-height="titleAreaHeight"
|
||||
@pointerenter="pointerInsideChart = true"
|
||||
@pointerleave="pointerInsideChart = false"
|
||||
@@ -192,8 +198,8 @@ const {
|
||||
:ref="setSharedOverlayElement"
|
||||
class="waveform-chart__overlay waveform-chart__overlay--shared"
|
||||
:class="{
|
||||
'is-zoomable': zoomable && isZoomMode,
|
||||
'is-annotating': activeInteractionMode === 'annotation',
|
||||
'is-zoomable': !isPresentationMode && zoomable && isZoomMode,
|
||||
'is-annotating': !isPresentationMode && activeInteractionMode === 'annotation',
|
||||
}"
|
||||
:width="innerWidth"
|
||||
:height="innerHeight"
|
||||
@@ -201,7 +207,7 @@ const {
|
||||
@pointerdown="beginSharedViewportDrag"
|
||||
@pointerup="finishViewportDrag"
|
||||
@pointercancel="cancelViewportDrag"
|
||||
@pointerleave="clearHover"
|
||||
@pointerleave="handlePointerLeave"
|
||||
@click="handleAnnotationClick"
|
||||
@contextmenu="handleAnnotationContextMenu"
|
||||
/>
|
||||
@@ -214,6 +220,7 @@ const {
|
||||
:clip-path-id="clipPathId"
|
||||
:inner-width="innerWidth"
|
||||
:zoomable="zoomable"
|
||||
:interactive="!isPresentationMode"
|
||||
:display-mode="displayMode"
|
||||
:interaction-mode="activeInteractionMode"
|
||||
:frame-number="resolveFrameNumber(track.index)"
|
||||
@@ -227,7 +234,7 @@ const {
|
||||
@pointer-down="beginViewportDrag($event, track.index, true)"
|
||||
@pointer-up="finishViewportDrag"
|
||||
@pointer-cancel="cancelViewportDrag"
|
||||
@pointer-leave="clearHover"
|
||||
@pointer-leave="handlePointerLeave"
|
||||
@click="handleAnnotationClick($event, track.index)"
|
||||
@contextmenu="handleAnnotationContextMenu($event, track.index)"
|
||||
/>
|
||||
@@ -236,11 +243,11 @@ const {
|
||||
:state="hoverState"
|
||||
:tracks="trackLayouts"
|
||||
:clip-path-id="clipPathId"
|
||||
:visible="showTooltip"
|
||||
:visible="showTooltip && !isPresentationMode"
|
||||
/>
|
||||
|
||||
<rect
|
||||
v-if="selectionBox && selection?.mode === 'box'"
|
||||
v-if="selectionBox && selection?.kind === 'box'"
|
||||
class="waveform-chart__zoom-selection"
|
||||
:x="selectionBox.x"
|
||||
:y="selectionBox.y"
|
||||
@@ -253,6 +260,7 @@ const {
|
||||
v-if="!isCleanView"
|
||||
:annotations="renderedAnnotations"
|
||||
:visible="annotationsVisible"
|
||||
:interactive="!isPresentationMode"
|
||||
@contextmenu="handleExistingAnnotationContextMenu"
|
||||
@drag-start="beginAnnotationDrag"
|
||||
@move="handleAnnotationMove"
|
||||
@@ -282,18 +290,18 @@ const {
|
||||
/>
|
||||
</g>
|
||||
</g>
|
||||
|
||||
<text
|
||||
v-if="resolvedXLabel && !isCleanView"
|
||||
class="waveform-chart__label waveform-chart__x-label"
|
||||
:x="innerWidth / 2"
|
||||
:y="xAxisTitleY"
|
||||
text-anchor="middle"
|
||||
>
|
||||
{{ resolvedXLabel }}
|
||||
</text>
|
||||
</g>
|
||||
|
||||
<text
|
||||
v-if="resolvedXLabel && !isCleanView"
|
||||
class="waveform-chart__label waveform-chart__x-label"
|
||||
:x="resolvedChartLeftMargin + innerWidth / 2"
|
||||
:y="xAxisTitleY"
|
||||
text-anchor="middle"
|
||||
>
|
||||
{{ resolvedXLabel }}
|
||||
</text>
|
||||
|
||||
<text
|
||||
v-if="hasChartArea && !hasWaveformData"
|
||||
class="waveform-chart__empty"
|
||||
@@ -318,7 +326,7 @@ const {
|
||||
/>
|
||||
|
||||
<WaveformAnnotationEditor
|
||||
v-if="editorDraft && !isCleanView"
|
||||
v-if="editorDraft && !isCleanView && !isPresentationMode"
|
||||
:annotation="editorDraft.annotation"
|
||||
:mode="editorDraft.mode"
|
||||
:series="editorSeries"
|
||||
@@ -330,7 +338,7 @@ const {
|
||||
/>
|
||||
|
||||
<WaveformAnnotationContextMenu
|
||||
v-if="!isCleanView"
|
||||
v-if="!isCleanView && !isPresentationMode"
|
||||
:visible="contextMenu !== null"
|
||||
:x="contextMenu?.x || 0"
|
||||
:y="contextMenu?.y || 0"
|
||||
@@ -342,7 +350,7 @@ const {
|
||||
|
||||
<WaveformHoverHost
|
||||
:state="hoverState"
|
||||
:visible="showTooltip"
|
||||
:visible="showTooltip && !isPresentationMode"
|
||||
:time-unit="timeUnit"
|
||||
:container-width="chartWidth"
|
||||
:container-height="chartHeight"
|
||||
|
||||
@@ -7,9 +7,12 @@ import { ANNOTATION_TEXT_FONT, ANNOTATION_TEXT_LINE_HEIGHT } from './markup'
|
||||
interface Props {
|
||||
annotations: RenderedAnnotation[]
|
||||
visible: boolean
|
||||
interactive?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
interactive: true,
|
||||
})
|
||||
const emit = defineEmits<{
|
||||
(event: 'contextmenu', annotationId: string, mouseEvent: MouseEvent): void
|
||||
(event: 'drag-start'): void
|
||||
@@ -79,7 +82,7 @@ function scheduleDragFrame() {
|
||||
}
|
||||
|
||||
function handlePointerDown(rendered: RenderedAnnotation, event: PointerEvent) {
|
||||
if (event.button !== 0 || dragState) return
|
||||
if (!props.interactive || event.button !== 0 || dragState) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const target = event.currentTarget as SVGGElement | null
|
||||
@@ -108,6 +111,7 @@ function handlePointerDown(rendered: RenderedAnnotation, event: PointerEvent) {
|
||||
}
|
||||
|
||||
function handlePointerMove(event: PointerEvent) {
|
||||
if (!props.interactive) return
|
||||
event.stopPropagation()
|
||||
if (!dragState || event.pointerId !== dragState.pointerId) return
|
||||
const deltaX = event.clientX - dragState.startX
|
||||
@@ -119,6 +123,7 @@ function handlePointerMove(event: PointerEvent) {
|
||||
}
|
||||
|
||||
function finishPointerDrag(event: PointerEvent) {
|
||||
if (!props.interactive) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!dragState || event.pointerId !== dragState.pointerId) return
|
||||
@@ -181,6 +186,11 @@ function handlePointerCancel(event: PointerEvent) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!dragState || event.pointerId !== dragState.pointerId) return
|
||||
cancelActiveDrag()
|
||||
}
|
||||
|
||||
function cancelActiveDrag() {
|
||||
if (!dragState) return
|
||||
const state = dragState
|
||||
dragState = null
|
||||
dragOffsets.value = new Map(dragOffsets.value).set(state.annotationId, { x: 0, y: 0 })
|
||||
@@ -188,10 +198,21 @@ function handlePointerCancel(event: PointerEvent) {
|
||||
cancelAnimationFrame(dragFrame)
|
||||
dragFrame = null
|
||||
}
|
||||
if (state.target.hasPointerCapture(state.pointerId)) {
|
||||
state.target.releasePointerCapture(state.pointerId)
|
||||
}
|
||||
emit('drag-end', true)
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.interactive,
|
||||
(interactive) => {
|
||||
if (!interactive) cancelActiveDrag()
|
||||
},
|
||||
)
|
||||
|
||||
function handleContextMenu(annotationId: string, event: MouseEvent) {
|
||||
if (!props.interactive) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
// 使用时间戳比较:如果 contextmenu 在拖动结束后 100ms 内触发,则抑制
|
||||
@@ -206,7 +227,11 @@ function markerId(annotationId: string) {
|
||||
</script>
|
||||
|
||||
<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
|
||||
v-for="rendered in props.annotations"
|
||||
:key="rendered.annotation.id"
|
||||
@@ -281,13 +306,13 @@ function markerId(annotationId: string) {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.waveform-annotation {
|
||||
.waveform-annotation-layer--interactive .waveform-annotation {
|
||||
pointer-events: auto;
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.waveform-annotation:active {
|
||||
.waveform-annotation-layer--interactive .waveform-annotation:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
@@ -300,6 +325,9 @@ function markerId(annotationId: string) {
|
||||
.waveform-annotation__box {
|
||||
stroke-width: 1;
|
||||
rx: 3;
|
||||
}
|
||||
|
||||
.waveform-annotation-layer--interactive .waveform-annotation__box {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -38,6 +38,7 @@ interface AnnotationContext {
|
||||
titleAreaHeight: ComputedRef<number>
|
||||
chartTopMargin: ComputedRef<number>
|
||||
activeInteractionMode: ComputedRef<WaveformInteractionMode | undefined>
|
||||
isPresentationMode: ComputedRef<boolean>
|
||||
}
|
||||
|
||||
interface AnnotationCandidateContext {
|
||||
@@ -75,6 +76,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
|
||||
titleAreaHeight,
|
||||
chartTopMargin,
|
||||
activeInteractionMode,
|
||||
isPresentationMode,
|
||||
} = context
|
||||
|
||||
function toggleSeriesVisibility(seriesId: string) {
|
||||
@@ -140,6 +142,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
|
||||
}
|
||||
|
||||
function changeDraftSeries(seriesId: string) {
|
||||
if (isPresentationMode.value) return
|
||||
const draft = annotationInteraction.editorDraft.value
|
||||
const candidate = editorSeriesOptions.value.find((item) => item.seriesId === seriesId)
|
||||
const track = trackLayouts.value.find((item) =>
|
||||
@@ -216,7 +219,13 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
|
||||
}
|
||||
|
||||
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)
|
||||
if (!candidateContext) return
|
||||
const nearby = nearbyCandidates(candidateContext.candidates)
|
||||
@@ -238,7 +247,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
|
||||
}
|
||||
|
||||
function handleAnnotationContextMenu(event: MouseEvent, trackIndex?: number) {
|
||||
if (!props.annotationsVisible) return
|
||||
if (isPresentationMode.value || !props.annotationsVisible) return
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const candidateContext = resolveAnnotationCandidates(event, trackIndex)
|
||||
@@ -252,7 +261,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
|
||||
}
|
||||
|
||||
function handleExistingAnnotationContextMenu(annotationId: string, event: MouseEvent) {
|
||||
if (!props.annotationsVisible || !container.value) return
|
||||
if (isPresentationMode.value || !props.annotationsVisible || !container.value) return
|
||||
editorSeriesOptions.value = []
|
||||
const annotation = props.annotations.find((item) => item.id === annotationId)
|
||||
if (!annotation) return
|
||||
@@ -270,6 +279,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
|
||||
}
|
||||
|
||||
function handleAnnotationMove(annotationId: string, offsetX: number, offsetY: number) {
|
||||
if (isPresentationMode.value) return
|
||||
const annotation = props.annotations.find((item) => item.id === annotationId)
|
||||
if (!annotation || !Number.isFinite(offsetX) || !Number.isFinite(offsetY)) return
|
||||
emit(
|
||||
@@ -281,6 +291,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
|
||||
}
|
||||
|
||||
function editContextAnnotation() {
|
||||
if (isPresentationMode.value) return
|
||||
const menu = annotationInteraction.contextMenu.value
|
||||
const annotation = props.annotations.find((item) => item.id === menu?.annotationId)
|
||||
if (!annotation) return
|
||||
@@ -302,6 +313,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
|
||||
}
|
||||
|
||||
function deleteContextAnnotation() {
|
||||
if (isPresentationMode.value) return
|
||||
const annotationId = annotationInteraction.contextMenu.value?.annotationId
|
||||
const annotation = props.annotations.find((item) => item.id === annotationId)
|
||||
if (!annotation) return
|
||||
@@ -314,6 +326,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
|
||||
}
|
||||
|
||||
function confirmAnnotation(annotation: WaveformAnnotation) {
|
||||
if (isPresentationMode.value) return
|
||||
const draft = annotationInteraction.editorDraft.value
|
||||
if (!draft) return
|
||||
if (draft.mode === 'add') {
|
||||
|
||||
@@ -7,6 +7,9 @@
|
||||
/** 图表边距 */
|
||||
export const margin = { top: 18, right: 24, bottom: 52, left: 64 }
|
||||
|
||||
/** Distance from the drawing SVG bottom edge to the X-axis title baseline. */
|
||||
export const X_AXIS_TITLE_BOTTOM_OFFSET = 12
|
||||
|
||||
/**
|
||||
* 图表最小高度(像素)
|
||||
*/
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -192,7 +192,7 @@ describe('multi-value Y-axis grouping', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('places left and right scientific exponents eight pixels outside their tick labels', () => {
|
||||
it('reserves label clearance for exponent-prefixed ticks on both axis sides', () => {
|
||||
const layout = buildTrackLayouts({
|
||||
cells: [
|
||||
{
|
||||
@@ -206,7 +206,7 @@ describe('multi-value Y-axis grouping', () => {
|
||||
plotHeight: 300,
|
||||
cellHeight: 330,
|
||||
xAxisBand: 30,
|
||||
series: track([series('left', 0, 254), series('right', 0, 254)]),
|
||||
series: track([series('left', 1000, 3000), series('right', 1000, 3000)]),
|
||||
},
|
||||
],
|
||||
grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} },
|
||||
@@ -222,14 +222,13 @@ describe('multi-value Y-axis grouping', () => {
|
||||
})[0]
|
||||
|
||||
expect(
|
||||
layout?.yAxes.map(({ side, x, exponentX, exponentLabel }) => ({
|
||||
layout?.yAxes.map(({ side, x, labelX }) => ({
|
||||
side,
|
||||
offset: Math.abs(exponentX - x),
|
||||
exponentLabel,
|
||||
labelOffset: Math.abs(labelX - x),
|
||||
})),
|
||||
).toEqual([
|
||||
{ side: 'left', offset: 43, exponentLabel: 'E+02' },
|
||||
{ side: 'right', offset: 43, exponentLabel: 'E+02' },
|
||||
{ side: 'left', labelOffset: 67 },
|
||||
{ side: 'right', labelOffset: 67 },
|
||||
])
|
||||
})
|
||||
|
||||
@@ -237,7 +236,7 @@ describe('multi-value Y-axis grouping', () => {
|
||||
const [group] = buildYAxisSeriesGroups(track([series('long', -1e120, 1e120)]), 'multi-axis')
|
||||
|
||||
expect(group).toBeDefined()
|
||||
expect(measureYAxisGroupClearance(group!)).toBe(119)
|
||||
expect(measureYAxisGroupClearance(group!)).toBe(90)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,12 +1,18 @@
|
||||
import { scaleLinear } from 'd3'
|
||||
|
||||
import type { WaveformOverlayMode } from '../../types'
|
||||
import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../../utils'
|
||||
import { formatScientificAxisLabel, paddedDomain } from '../../utils'
|
||||
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
|
||||
import { MAX_MULTI_Y_AXIS_COUNT, Y_AXIS_EXPONENT_GAP } from './constants'
|
||||
import { MAX_MULTI_Y_AXIS_COUNT } from './constants'
|
||||
import {
|
||||
mergeYDomains,
|
||||
resolveSeriesFixedYDomain,
|
||||
resolveTrackFixedYDomain,
|
||||
type WaveformYDomain,
|
||||
} from './yDomain'
|
||||
|
||||
// 导出常量供外部使用
|
||||
export { MAX_MULTI_Y_AXIS_COUNT, Y_AXIS_EXPONENT_GAP } from './constants'
|
||||
export { MAX_MULTI_Y_AXIS_COUNT } from './constants'
|
||||
|
||||
const Y_AXIS_CHARACTER_WIDTH = 7
|
||||
const Y_AXIS_TICK_PADDING = 7
|
||||
@@ -14,11 +20,12 @@ const Y_AXIS_OUTER_PADDING = 4
|
||||
const Y_AXIS_LABEL_GAP = 6
|
||||
const Y_AXIS_LABEL_BAND_WIDTH = 24
|
||||
|
||||
interface YAxisSeriesGroup {
|
||||
export interface YAxisSeriesGroup {
|
||||
index: number
|
||||
side: 'left' | 'right'
|
||||
seriesList: DisplaySeries[]
|
||||
domain: [number, number]
|
||||
fixed: boolean
|
||||
}
|
||||
|
||||
function resolveAxisSides(axisCount: number): Array<'left' | 'right'> {
|
||||
@@ -53,6 +60,7 @@ export function buildYAxisSeriesGroups(
|
||||
side: sides[index],
|
||||
seriesList: [] as DisplaySeries[],
|
||||
domain: [0, 1] as [number, number],
|
||||
fixed: false,
|
||||
}))
|
||||
|
||||
track.visibleSeries.forEach((series, index) => {
|
||||
@@ -72,35 +80,49 @@ export function buildYAxisSeriesGroups(
|
||||
return grouped
|
||||
}
|
||||
|
||||
export function axisTextMetrics(domain: [number, number]): {
|
||||
exponentLabel: string | null
|
||||
exponentWidth: number
|
||||
tickTextWidth: number
|
||||
} {
|
||||
const scale = scaleLinear(domain, [1, 0]).nice()
|
||||
export function resolveYAxisSeriesGroups(
|
||||
track: DisplayTrack,
|
||||
overlayMode: WaveformOverlayMode,
|
||||
yDomain?: WaveformYDomain,
|
||||
yDomains?: Record<string, WaveformYDomain>,
|
||||
): YAxisSeriesGroup[] {
|
||||
const trackDomain = resolveTrackFixedYDomain(track, yDomains)
|
||||
return buildYAxisSeriesGroups(track, overlayMode).map((group) => {
|
||||
if (trackDomain) return { ...group, domain: trackDomain, fixed: true }
|
||||
const seriesDomains = group.seriesList.map(
|
||||
(series) => resolveSeriesFixedYDomain(series, yDomain, yDomains) ?? series.yDomain,
|
||||
)
|
||||
const fixed = group.seriesList.some((series) =>
|
||||
resolveSeriesFixedYDomain(series, yDomain, yDomains),
|
||||
)
|
||||
return fixed ? { ...group, domain: mergeYDomains(seriesDomains), fixed } : group
|
||||
})
|
||||
}
|
||||
|
||||
export function axisTextMetrics(
|
||||
domain: [number, number],
|
||||
nice = true,
|
||||
tickValues?: number[],
|
||||
): { tickTextWidth: number } {
|
||||
const scale = scaleLinear(domain, [1, 0])
|
||||
if (nice) scale.nice()
|
||||
const [axisMin, axisMax] = scale.domain()
|
||||
const values = scale.ticks(10)
|
||||
const values = tickValues ?? Array.from(new Set([axisMin, ...scale.ticks(10), axisMax]))
|
||||
const topTickValue = Math.max(...values)
|
||||
const maximumTickCharacters = Math.max(
|
||||
1,
|
||||
...values.map((value) => formatScientificAxisLabel(value, { axisMin, axisMax }).length),
|
||||
...values.map(
|
||||
(value) => formatScientificAxisLabel(value, { axisMin, axisMax, topTickValue }).length,
|
||||
),
|
||||
)
|
||||
const exponentLabel = formatScientificAxisExponent(axisMin, axisMax)
|
||||
return {
|
||||
exponentLabel,
|
||||
exponentWidth: exponentLabel ? exponentLabel.length * Y_AXIS_CHARACTER_WIDTH : 0,
|
||||
tickTextWidth: maximumTickCharacters * Y_AXIS_CHARACTER_WIDTH,
|
||||
}
|
||||
}
|
||||
|
||||
function axisExponentClearance(domain: [number, number]): number {
|
||||
const { exponentLabel, exponentWidth } = axisTextMetrics(domain)
|
||||
return exponentLabel ? exponentWidth + Y_AXIS_EXPONENT_GAP : 0
|
||||
}
|
||||
|
||||
export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
|
||||
return (
|
||||
axisTextMetrics(group.domain).tickTextWidth +
|
||||
axisExponentClearance(group.domain) +
|
||||
axisTextMetrics(group.domain, !group.fixed).tickTextWidth +
|
||||
Y_AXIS_TICK_PADDING +
|
||||
Y_AXIS_LABEL_GAP +
|
||||
Y_AXIS_LABEL_BAND_WIDTH +
|
||||
@@ -110,8 +132,7 @@ export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number {
|
||||
|
||||
function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number {
|
||||
return (
|
||||
axisTextMetrics(group.domain).tickTextWidth +
|
||||
axisExponentClearance(group.domain) +
|
||||
axisTextMetrics(group.domain, !group.fixed).tickTextWidth +
|
||||
Y_AXIS_TICK_PADDING +
|
||||
Y_AXIS_OUTER_PADDING
|
||||
)
|
||||
@@ -120,8 +141,10 @@ function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number {
|
||||
export function measureTrackYAxisClearance(
|
||||
track: DisplayTrack,
|
||||
overlayMode: WaveformOverlayMode,
|
||||
yDomain?: WaveformYDomain,
|
||||
yDomains?: Record<string, WaveformYDomain>,
|
||||
): { left: number; right: number } {
|
||||
return buildYAxisSeriesGroups(track, overlayMode).reduce(
|
||||
return resolveYAxisSeriesGroups(track, overlayMode, yDomain, yDomains).reduce(
|
||||
(clearance, group) => {
|
||||
clearance[group.side] +=
|
||||
overlayMode === 'multi-axis' || track.visibleSeries.length === 1
|
||||
|
||||
@@ -12,16 +12,20 @@ import {
|
||||
selectSeriesRenderPoints,
|
||||
type ResolvedWaveformRenderingOptions,
|
||||
} from '../../core/rendering'
|
||||
import type { WaveformDisplayMode, WaveformOverlayMode, WaveformPoint } from '../../types'
|
||||
import { buildMinorTicks, formatAxisTimeExponent, formatEndpointTime } from '../../utils'
|
||||
import type {
|
||||
WaveformDisplayMode,
|
||||
WaveformOverlayMode,
|
||||
WaveformPoint,
|
||||
WaveformXAxisLabelFormatter,
|
||||
} from '../../types'
|
||||
import { buildMinorTicks, formatXAxisLabel } from '../../utils'
|
||||
import {
|
||||
getBottomRowCellIndexes,
|
||||
type GridCellGeometry,
|
||||
type NormalizedWaveformGridOptions,
|
||||
} from './grid'
|
||||
import { axisTextMetrics, buildYAxisSeriesGroups } from './layout'
|
||||
import { axisTextMetrics, resolveYAxisSeriesGroups } from './layout'
|
||||
import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
|
||||
import { Y_AXIS_EXPONENT_GAP } from './constants'
|
||||
import {
|
||||
Y_AXIS_LABEL_BAND_WIDTH,
|
||||
Y_AXIS_LABEL_GAP,
|
||||
@@ -42,8 +46,11 @@ export interface BuildTrackLayoutsOptions {
|
||||
sharedZoomDomain: [number, number]
|
||||
initialXDomain?: [number, number]
|
||||
initialXDomains?: Record<string, [number, number]>
|
||||
fixedYDomain?: [number, number]
|
||||
fixedYDomains?: Record<string, [number, number]>
|
||||
yDomains?: Record<string, [number, number]>
|
||||
timeUnit: 's' | 'ms'
|
||||
xAxisLabelFormatter?: WaveformXAxisLabelFormatter
|
||||
rendering: ResolvedWaveformRenderingOptions
|
||||
hideSecondaryLabels: boolean
|
||||
yAxisLabelX: number
|
||||
@@ -84,6 +91,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
? scaleLinear(
|
||||
options.initialXDomains?.[displayTrack.id] ??
|
||||
options.initialXDomains?.[series.id] ??
|
||||
options.initialXDomain ??
|
||||
displayTrack.xDomain,
|
||||
[0, cell.width],
|
||||
)
|
||||
@@ -94,13 +102,18 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
: zoomIdentity
|
||||
const xScale = transform.rescaleX(baseXScale)
|
||||
const configuredYDomain = options.yDomains?.[displayTrack.id]
|
||||
const yAxisGroups = buildYAxisSeriesGroups(displayTrack, options.overlayMode).map((group) => ({
|
||||
...group,
|
||||
domain: configuredYDomain ?? group.domain,
|
||||
}))
|
||||
const yAxisGroups = resolveYAxisSeriesGroups(
|
||||
displayTrack,
|
||||
options.overlayMode,
|
||||
options.fixedYDomain,
|
||||
options.fixedYDomains,
|
||||
).map((group) =>
|
||||
!group.fixed && configuredYDomain ? { ...group, domain: configuredYDomain } : group,
|
||||
)
|
||||
const sideOffsets = { left: 0, right: 0 }
|
||||
const yAxes: WaveformYAxisLayout[] = yAxisGroups.map((group) => {
|
||||
const scale = scaleLinear(group.domain, [cell.plotHeight, 0]).nice()
|
||||
const scale = scaleLinear(group.domain, [cell.plotHeight, 0])
|
||||
if (!group.fixed) scale.nice()
|
||||
const majorTicks = scale.ticks(Math.max(2, Math.floor(cell.plotHeight / 55)))
|
||||
const [axisStart, axisEnd] = scale.domain()
|
||||
const showAxisEnd = options.displayMode !== 'compact' || cell.row === 0
|
||||
@@ -110,28 +123,16 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
const tickValues = Array.from(
|
||||
new Set([axisStart, ...visibleMajorTicks, ...(showAxisEnd ? [axisEnd] : [])]),
|
||||
)
|
||||
const { exponentLabel, exponentWidth, tickTextWidth } = axisTextMetrics(group.domain)
|
||||
const exponentClearance = exponentLabel ? exponentWidth + Y_AXIS_EXPONENT_GAP : 0
|
||||
const { tickTextWidth } = axisTextMetrics(group.domain, !group.fixed, tickValues)
|
||||
const clearance =
|
||||
tickTextWidth +
|
||||
Y_AXIS_TICK_PADDING +
|
||||
exponentClearance +
|
||||
Y_AXIS_LABEL_GAP +
|
||||
Y_AXIS_LABEL_BAND_WIDTH +
|
||||
Y_AXIS_OUTER_PADDING
|
||||
const x = group.side === 'left' ? -sideOffsets.left : cell.width + sideOffsets.right
|
||||
const exponentX =
|
||||
x +
|
||||
(group.side === 'left'
|
||||
? -(Y_AXIS_TICK_PADDING + tickTextWidth + Y_AXIS_EXPONENT_GAP)
|
||||
: Y_AXIS_TICK_PADDING + tickTextWidth + Y_AXIS_EXPONENT_GAP)
|
||||
const labelDistance =
|
||||
tickTextWidth +
|
||||
Y_AXIS_TICK_PADDING +
|
||||
exponentClearance +
|
||||
exponentWidth +
|
||||
Y_AXIS_LABEL_GAP +
|
||||
Y_AXIS_LABEL_BAND_WIDTH / 2
|
||||
tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_BAND_WIDTH / 2
|
||||
const labelX = x + (group.side === 'left' ? -labelDistance : labelDistance)
|
||||
sideOffsets[group.side] += clearance
|
||||
return {
|
||||
@@ -139,8 +140,6 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
side: group.side,
|
||||
x,
|
||||
labelX,
|
||||
exponentX,
|
||||
exponentLabel,
|
||||
scale,
|
||||
majorTicks,
|
||||
minorTicks: buildMinorTicks(majorTicks),
|
||||
@@ -154,10 +153,21 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
const yAxisTickValues = yAxes[0]?.tickValues ?? []
|
||||
const domain = xScale.domain() as [number, number]
|
||||
const endpointLabels = {
|
||||
start: formatEndpointTime(domain[0], domain, options.timeUnit),
|
||||
end: formatEndpointTime(domain[1], domain, options.timeUnit),
|
||||
start: formatXAxisLabel(
|
||||
domain[0],
|
||||
domain,
|
||||
options.timeUnit,
|
||||
'start',
|
||||
options.xAxisLabelFormatter,
|
||||
),
|
||||
end: formatXAxisLabel(
|
||||
domain[1],
|
||||
domain,
|
||||
options.timeUnit,
|
||||
'end',
|
||||
options.xAxisLabelFormatter,
|
||||
),
|
||||
}
|
||||
const xAxisExponent = formatAxisTimeExponent(domain, options.timeUnit)
|
||||
const leftClearance = endpointLabels.start.length * 7 + 10
|
||||
const rightClearance = endpointLabels.end.length * 7 + 10
|
||||
const xAxisTickValues = xMajorTicks.filter((tick) => {
|
||||
@@ -224,7 +234,6 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
||||
yAxisTickValues,
|
||||
xAxisTickValues,
|
||||
endpointLabels,
|
||||
xAxisExponent,
|
||||
path: seriesPaths[0]?.path ?? null,
|
||||
seriesPaths,
|
||||
gridLines: options.grid.trackLines[displayTrack.id] ?? {
|
||||
|
||||
@@ -51,8 +51,6 @@ export interface WaveformYAxisLayout {
|
||||
side: 'left' | 'right'
|
||||
x: number
|
||||
labelX: number
|
||||
exponentX: number
|
||||
exponentLabel: string | null
|
||||
scale: ScaleLinear<number, number>
|
||||
majorTicks: number[]
|
||||
minorTicks: number[]
|
||||
@@ -109,7 +107,6 @@ export interface TrackLayout {
|
||||
yAxisTickValues: number[]
|
||||
xAxisTickValues: number[]
|
||||
endpointLabels: { start: string; end: string }
|
||||
xAxisExponent: string | null
|
||||
path: string | null
|
||||
seriesPaths: TrackSeriesPath[]
|
||||
showXAxis: boolean
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
shallowReactive,
|
||||
shallowRef,
|
||||
toRefs,
|
||||
watch,
|
||||
watchEffect,
|
||||
type ComponentPublicInstance,
|
||||
type Ref,
|
||||
@@ -68,12 +69,23 @@ export function useWaveformChartController(
|
||||
const annotationInteraction = useWaveformAnnotationInteraction()
|
||||
const editorSeriesOptions = ref<AnnotationSeriesCandidate[]>([])
|
||||
const hoverThrottle = useAnimationFrameThrottle()
|
||||
const selection = ref<ViewportSelectionState | null>(null)
|
||||
|
||||
watch(
|
||||
[() => props.yDomain, () => props.yDomains],
|
||||
() => {
|
||||
sharedYDomains.value = {}
|
||||
independentYDomains.value = {}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
const selection = shallowRef<ViewportSelectionState | null>(null)
|
||||
const spacePressed = ref(false)
|
||||
const pointerInsideChart = ref(false)
|
||||
let handleBeforeDataReferenceChange: () => void = () => undefined
|
||||
let handleDataReferenceChange: () => void = () => undefined
|
||||
const preparedSeries = usePreparedWaveformSeries(
|
||||
() => props.data,
|
||||
() => handleBeforeDataReferenceChange(),
|
||||
() => handleDataReferenceChange(),
|
||||
)
|
||||
|
||||
@@ -90,6 +102,7 @@ export function useWaveformChartController(
|
||||
chartWidth,
|
||||
chartHeight,
|
||||
isCleanView,
|
||||
isPresentationMode,
|
||||
hiddenSeriesIdSet,
|
||||
resolvedTitleText,
|
||||
titleAreaReserved,
|
||||
@@ -116,6 +129,7 @@ export function useWaveformChartController(
|
||||
const {
|
||||
chartSeries,
|
||||
chartTracks,
|
||||
trackLayouts,
|
||||
gridOptions,
|
||||
pageCount,
|
||||
pagedTracks,
|
||||
@@ -127,7 +141,6 @@ export function useWaveformChartController(
|
||||
initialXDomain,
|
||||
sharedZoomDomain,
|
||||
resolveInitialTrackDomain,
|
||||
trackLayouts,
|
||||
annotationLayoutsForTrack,
|
||||
resolveSeriesYScale,
|
||||
} = layout
|
||||
@@ -151,6 +164,7 @@ export function useWaveformChartController(
|
||||
innerHeight,
|
||||
hasChartArea,
|
||||
isZoomMode,
|
||||
isPresentationMode,
|
||||
resolveInitialTrackDomain,
|
||||
cancelPendingHover: () => hover.cancelPendingHover(),
|
||||
})
|
||||
@@ -174,6 +188,7 @@ export function useWaveformChartController(
|
||||
titleAreaHeight,
|
||||
chartTopMargin,
|
||||
activeInteractionMode,
|
||||
isPresentationMode,
|
||||
})
|
||||
|
||||
const viewport = useWaveformViewport({
|
||||
@@ -192,6 +207,7 @@ export function useWaveformChartController(
|
||||
sharedYDomains,
|
||||
independentYDomains,
|
||||
isZoomMode,
|
||||
isPresentationMode,
|
||||
editorSeriesOptions,
|
||||
resolveInitialTrackDomain,
|
||||
canZoomTrack: zoom.canZoomTrack,
|
||||
@@ -214,6 +230,7 @@ export function useWaveformChartController(
|
||||
titleAreaHeight,
|
||||
chartTopMargin,
|
||||
sharedOverlayElement,
|
||||
isPresentationMode,
|
||||
updateViewportDrag: viewport.updateViewportDrag,
|
||||
resolveTrackAtPointer: annotations.resolveTrackAtPointer,
|
||||
})
|
||||
@@ -239,6 +256,7 @@ export function useWaveformChartController(
|
||||
gridOptions,
|
||||
chartSeries,
|
||||
chartTracks,
|
||||
trackLayouts,
|
||||
innerWidth,
|
||||
innerHeight,
|
||||
activeInteractionMode,
|
||||
@@ -246,15 +264,19 @@ export function useWaveformChartController(
|
||||
internalHiddenSeriesIds,
|
||||
independentTransforms,
|
||||
independentYDomains,
|
||||
resolveInitialTrackDomain,
|
||||
annotationInteraction,
|
||||
editorSeriesOptions,
|
||||
isPresentationMode,
|
||||
clearHover: hover.clearHover,
|
||||
cancelAnnotation: annotations.cancelAnnotation,
|
||||
configureZoom: zoom.configureZoom,
|
||||
resetViewport: viewport.resetViewport,
|
||||
cancelViewportDrag: viewport.cancelViewportDrag,
|
||||
cancelPendingHover: hover.cancelPendingHover,
|
||||
clearZoomBindings: zoom.clearZoomBindings,
|
||||
})
|
||||
handleBeforeDataReferenceChange = lifecycle.handleBeforeDataReferenceChange
|
||||
handleDataReferenceChange = lifecycle.handleDataReferenceChange
|
||||
|
||||
return reactive({
|
||||
|
||||
@@ -11,8 +11,9 @@ import {
|
||||
|
||||
import type { AnnotationSeriesCandidate } from '../annotation'
|
||||
import type { useWaveformAnnotationInteraction } from '../annotation'
|
||||
import type { DisplaySeries, DisplayTrack } from './types'
|
||||
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
|
||||
import type { ResolvedWaveformChartProps, WaveformChartEmit } from './waveformChartTypes'
|
||||
import { constrainZoomDomain, transformForDomain } from '../interaction/zoomConstraints'
|
||||
|
||||
interface LifecycleContext {
|
||||
props: ResolvedWaveformChartProps
|
||||
@@ -35,6 +36,7 @@ interface LifecycleContext {
|
||||
gridOptions: ComputedRef<{ rowCount: number; columnCount: number }>
|
||||
chartSeries: ComputedRef<DisplaySeries[]>
|
||||
chartTracks: ComputedRef<DisplayTrack[]>
|
||||
trackLayouts: ComputedRef<TrackLayout[]>
|
||||
innerWidth: ComputedRef<number>
|
||||
innerHeight: ComputedRef<number>
|
||||
activeInteractionMode: ComputedRef<string | undefined>
|
||||
@@ -42,12 +44,15 @@ interface LifecycleContext {
|
||||
internalHiddenSeriesIds: Ref<Set<string>>
|
||||
independentTransforms: ShallowRef<ZoomTransform[]>
|
||||
independentYDomains: Ref<Record<number, [number, number]>>
|
||||
resolveInitialTrackDomain: (track: TrackLayout) => [number, number]
|
||||
annotationInteraction: ReturnType<typeof useWaveformAnnotationInteraction>
|
||||
editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
|
||||
isPresentationMode: ComputedRef<boolean>
|
||||
clearHover: () => void
|
||||
cancelAnnotation: () => void
|
||||
configureZoom: () => void
|
||||
resetViewport: () => void
|
||||
cancelViewportDrag: () => void
|
||||
cancelPendingHover: () => void
|
||||
clearZoomBindings: () => void
|
||||
}
|
||||
@@ -85,6 +90,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
||||
gridOptions,
|
||||
chartSeries,
|
||||
chartTracks,
|
||||
trackLayouts,
|
||||
innerWidth,
|
||||
innerHeight,
|
||||
activeInteractionMode,
|
||||
@@ -92,18 +98,28 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
||||
internalHiddenSeriesIds,
|
||||
independentTransforms,
|
||||
independentYDomains,
|
||||
resolveInitialTrackDomain,
|
||||
annotationInteraction,
|
||||
editorSeriesOptions,
|
||||
isPresentationMode,
|
||||
clearHover,
|
||||
cancelAnnotation,
|
||||
configureZoom,
|
||||
resetViewport,
|
||||
cancelViewportDrag,
|
||||
cancelPendingHover,
|
||||
clearZoomBindings,
|
||||
} = context
|
||||
|
||||
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
|
||||
spacePressed.value = true
|
||||
event.preventDefault()
|
||||
@@ -128,6 +144,27 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
||||
emit('page-change', nextPage, pageCount.value)
|
||||
}
|
||||
|
||||
let pendingIndependentXDomains: Array<[number, number] | undefined> | undefined
|
||||
|
||||
function handleBeforeDataReferenceChange() {
|
||||
if (props.displayMode !== 'independent') return
|
||||
pendingIndependentXDomains = trackLayouts.value.map((track) => {
|
||||
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 undefined
|
||||
}
|
||||
return track.xScale.domain() as [number, number]
|
||||
})
|
||||
}
|
||||
|
||||
function handleDataReferenceChange() {
|
||||
if (props.displayMode === 'independent') {
|
||||
const currentTransforms = independentTransforms.value
|
||||
@@ -137,7 +174,24 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
||||
}
|
||||
clearHover()
|
||||
editorSeriesOptions.value = []
|
||||
void nextTick(configureZoom)
|
||||
void nextTick(() => {
|
||||
if (props.displayMode === 'independent' && pendingIndependentXDomains) {
|
||||
const nextTransforms = chartTracks.value.map(() => zoomIdentity)
|
||||
trackLayouts.value.forEach((track) => {
|
||||
const previousDomain = pendingIndependentXDomains?.[track.index]
|
||||
if (!previousDomain) return
|
||||
const boundary = resolveInitialTrackDomain(track)
|
||||
const domain = constrainZoomDomain(previousDomain, boundary, [track.seriesList], props)
|
||||
nextTransforms[track.index] = transformForDomain(domain, boundary, track.width)
|
||||
})
|
||||
independentTransforms.value = nextTransforms
|
||||
pendingIndependentXDomains = undefined
|
||||
void nextTick(configureZoom)
|
||||
return
|
||||
}
|
||||
pendingIndependentXDomains = undefined
|
||||
configureZoom()
|
||||
})
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -145,7 +199,9 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
||||
innerWidth,
|
||||
innerHeight,
|
||||
() => props.zoomable,
|
||||
isPresentationMode,
|
||||
() => props.minZoomSpan,
|
||||
() => props.minVisiblePoints,
|
||||
() => props.initialXDomain,
|
||||
() => props.initialXDomains,
|
||||
() => props.displayMode,
|
||||
@@ -191,6 +247,16 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
||||
editorSeriesOptions.value = []
|
||||
})
|
||||
|
||||
watch(isPresentationMode, (enabled) => {
|
||||
spacePressed.value = false
|
||||
if (!enabled) return
|
||||
cancelViewportDrag()
|
||||
clearZoomBindings()
|
||||
clearHover()
|
||||
annotationInteraction.closeContextMenu()
|
||||
cancelAnnotation()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => chartSeries.value.map((series) => series.id).join('\u0000'),
|
||||
() => {
|
||||
@@ -302,5 +368,5 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
||||
editorSeriesOptions.value = []
|
||||
})
|
||||
|
||||
return { goToPage, handleDataReferenceChange }
|
||||
return { goToPage, handleBeforeDataReferenceChange, handleDataReferenceChange }
|
||||
}
|
||||
|
||||
@@ -62,9 +62,14 @@ export function prepareWaveformSeries(data: WaveformData): PreparedWaveformSerie
|
||||
})
|
||||
}
|
||||
|
||||
export function usePreparedWaveformSeries(data: () => WaveformData, onDataChange: () => void) {
|
||||
export function usePreparedWaveformSeries(
|
||||
data: () => WaveformData,
|
||||
onBeforeDataChange: () => void,
|
||||
onDataChange: () => void,
|
||||
) {
|
||||
const preparedSeries = shallowRef<PreparedWaveformSeries[]>(prepareWaveformSeries(data()))
|
||||
watch(data, (nextData) => {
|
||||
onBeforeDataChange()
|
||||
preparedSeries.value = prepareWaveformSeries(nextData)
|
||||
onDataChange()
|
||||
})
|
||||
|
||||
@@ -2,7 +2,7 @@ import { scaleLinear, type ZoomTransform } from 'd3'
|
||||
import { computed, type ComputedRef, type Ref, type ShallowRef } from 'vue'
|
||||
|
||||
import { resolveWaveformRenderingOptions } from '../../core'
|
||||
import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../../utils'
|
||||
import { paddedDomain } from '../../utils'
|
||||
import {
|
||||
layoutAnnotations,
|
||||
type AnnotationSeriesInfo,
|
||||
@@ -24,9 +24,13 @@ import {
|
||||
normalizeGridOptions,
|
||||
paginateSeries,
|
||||
resolveGridCellGeometry,
|
||||
X_AXIS_BAND,
|
||||
} from './grid'
|
||||
import { buildTrackLayouts, measureTrackYAxisClearance, Y_AXIS_EXPONENT_GAP } from './layout'
|
||||
import {
|
||||
buildTrackLayouts,
|
||||
axisTextMetrics,
|
||||
measureTrackYAxisClearance,
|
||||
resolveYAxisSeriesGroups,
|
||||
} from './layout'
|
||||
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
|
||||
import type { PreparedWaveformSeries } from './useWaveformData'
|
||||
import type { ResolvedWaveformChartProps } from './waveformChartTypes'
|
||||
@@ -104,39 +108,21 @@ export function useWaveformLayout(context: LayoutContext) {
|
||||
const yAxisMetrics = computed(() => {
|
||||
const axisText = chartTracks.value
|
||||
.filter((track) => track.visibleSeries.length > 0)
|
||||
.map((track) => {
|
||||
const scale = scaleLinear(track.yDomain, [1, 0]).nice()
|
||||
const [axisMin, axisMax] = scale.domain()
|
||||
return {
|
||||
exponentLabel: formatScientificAxisExponent(axisMin, axisMax),
|
||||
tickLabels: scale
|
||||
.ticks(10)
|
||||
.map((value) => formatScientificAxisLabel(value, { axisMin, axisMax })),
|
||||
}
|
||||
})
|
||||
const maximumCharacters = Math.max(
|
||||
1,
|
||||
...axisText.flatMap(({ tickLabels }) => tickLabels).map((label) => label.length),
|
||||
)
|
||||
const tickTextWidth = maximumCharacters * Y_AXIS_CHARACTER_WIDTH
|
||||
const maximumExponentWidth = Math.max(
|
||||
0,
|
||||
...axisText.map(({ exponentLabel }) => (exponentLabel?.length ?? 0) * Y_AXIS_CHARACTER_WIDTH),
|
||||
)
|
||||
const exponentClearance = maximumExponentWidth ? maximumExponentWidth + Y_AXIS_EXPONENT_GAP : 0
|
||||
const tickClearance =
|
||||
tickTextWidth + Y_AXIS_TICK_PADDING + exponentClearance + Y_AXIS_OUTER_PADDING
|
||||
.flatMap((track) =>
|
||||
resolveYAxisSeriesGroups(track, props.overlayMode, props.yDomain, props.yDomains),
|
||||
)
|
||||
.map((group) => axisTextMetrics(group.domain, !group.fixed).tickTextWidth)
|
||||
const tickTextWidth = Math.max(Y_AXIS_CHARACTER_WIDTH, ...axisText)
|
||||
const tickClearance = tickTextWidth + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING
|
||||
const labelCenterX = -(
|
||||
Y_AXIS_TICK_PADDING +
|
||||
tickTextWidth +
|
||||
exponentClearance +
|
||||
Y_AXIS_LABEL_GAP +
|
||||
Y_AXIS_LABEL_BAND_WIDTH / 2
|
||||
)
|
||||
const fullClearance =
|
||||
tickTextWidth +
|
||||
Y_AXIS_TICK_PADDING +
|
||||
exponentClearance +
|
||||
Y_AXIS_LABEL_GAP +
|
||||
Y_AXIS_LABEL_BAND_WIDTH +
|
||||
Y_AXIS_OUTER_PADDING
|
||||
@@ -165,7 +151,12 @@ export function useWaveformLayout(context: LayoutContext) {
|
||||
const multiAxisClearance = computed(() =>
|
||||
chartTracks.value.reduce(
|
||||
(maximum, track) => {
|
||||
const clearance = measureTrackYAxisClearance(track, props.overlayMode)
|
||||
const clearance = measureTrackYAxisClearance(
|
||||
track,
|
||||
props.overlayMode,
|
||||
props.yDomain,
|
||||
props.yDomains,
|
||||
)
|
||||
return {
|
||||
left: Math.max(maximum.left, clearance.left),
|
||||
right: Math.max(maximum.right, clearance.right),
|
||||
@@ -281,6 +272,8 @@ export function useWaveformLayout(context: LayoutContext) {
|
||||
sharedZoomDomain: sharedZoomDomain.value,
|
||||
initialXDomain: props.initialXDomain ? initialXDomain.value : undefined,
|
||||
initialXDomains: props.initialXDomains,
|
||||
fixedYDomain: props.yDomain,
|
||||
fixedYDomains: props.yDomains,
|
||||
yDomains:
|
||||
props.displayMode === 'independent'
|
||||
? Object.fromEntries(
|
||||
@@ -291,6 +284,7 @@ export function useWaveformLayout(context: LayoutContext) {
|
||||
)
|
||||
: sharedYDomains.value,
|
||||
timeUnit: props.timeUnit,
|
||||
xAxisLabelFormatter: props.axes?.x?.labelFormatter,
|
||||
rendering: renderingOptions.value,
|
||||
hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
|
||||
yAxisLabelX: yAxisMetrics.value.labelCenterX,
|
||||
@@ -321,7 +315,6 @@ export function useWaveformLayout(context: LayoutContext) {
|
||||
)
|
||||
: [],
|
||||
)
|
||||
const xAxisTitleY = computed(() => innerHeight.value + X_AXIS_BAND + 10)
|
||||
const editorSeries = computed<AnnotationSeriesInfo | undefined>(() => {
|
||||
const seriesId = annotationInteraction.editorDraft.value?.annotation.seriesId
|
||||
const series = chartSeries.value.find((item) => item.id === seriesId)
|
||||
@@ -365,7 +358,6 @@ export function useWaveformLayout(context: LayoutContext) {
|
||||
resolveSeriesYScale,
|
||||
annotationTrackLayouts,
|
||||
renderedAnnotations,
|
||||
xAxisTitleY,
|
||||
editorSeries,
|
||||
resolveFrameNumber,
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
TITLE_CHAR_WIDTH_RATIO,
|
||||
TITLE_DEFAULT_FONT_SIZE,
|
||||
TITLE_LINE_HEIGHT,
|
||||
X_AXIS_TITLE_BOTTOM_OFFSET,
|
||||
ZERO_LINE_DEFAULTS,
|
||||
} from './constants'
|
||||
import { calculateRotatedTitleLayout, TITLE_AREA_HORIZONTAL_PADDING } from './title'
|
||||
@@ -49,6 +50,7 @@ export function useWaveformPresentation(context: PresentationContext) {
|
||||
height: fixedHeight.value === undefined ? '100%' : `${fixedHeight.value}px`,
|
||||
}))
|
||||
const isCleanView = computed(() => props.cleanView === true)
|
||||
const isPresentationMode = computed(() => props.presentationMode === true)
|
||||
const resolvedZeroLine = computed(() => {
|
||||
const width = props.zeroLine.width
|
||||
return {
|
||||
@@ -143,11 +145,23 @@ export function useWaveformPresentation(context: PresentationContext) {
|
||||
const titleAreaHeight = computed(() =>
|
||||
titleAreaReserved.value ? titleLayout.value.areaHeight : 0,
|
||||
)
|
||||
const chartTopMargin = computed(() => margin.top)
|
||||
const resolvePlotMargin = (value: number | undefined, fallback: number) =>
|
||||
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
|
||||
const resolvedPlotMargin = computed(() => ({
|
||||
top: resolvePlotMargin(props.plotMargin.top, margin.top),
|
||||
bottom: resolvePlotMargin(props.plotMargin.bottom, margin.bottom),
|
||||
}))
|
||||
const chartTopMargin = computed(() => resolvedPlotMargin.value.top)
|
||||
const drawingHeight = computed(() =>
|
||||
Math.max(0, chartHeight.value - titleAreaHeight.value - paginationBandHeight.value),
|
||||
)
|
||||
const innerHeight = computed(() => Math.max(0, drawingHeight.value - margin.top - margin.bottom))
|
||||
const xAxisTitleY = computed(() => Math.max(0, drawingHeight.value - X_AXIS_TITLE_BOTTOM_OFFSET))
|
||||
const innerHeight = computed(() =>
|
||||
Math.max(
|
||||
0,
|
||||
drawingHeight.value - resolvedPlotMargin.value.top - resolvedPlotMargin.value.bottom,
|
||||
),
|
||||
)
|
||||
const titleAreaStyle = computed<CSSProperties>(() => ({
|
||||
height: `${titleAreaHeight.value}px`,
|
||||
justifyContent:
|
||||
@@ -178,6 +192,7 @@ export function useWaveformPresentation(context: PresentationContext) {
|
||||
chartHeight,
|
||||
containerStyle,
|
||||
isCleanView,
|
||||
isPresentationMode,
|
||||
resolvedZeroLine,
|
||||
legendBackgroundColor,
|
||||
legendInteractive,
|
||||
@@ -191,8 +206,10 @@ export function useWaveformPresentation(context: PresentationContext) {
|
||||
titleMeasureStyle,
|
||||
titleLayout,
|
||||
titleAreaHeight,
|
||||
resolvedPlotMargin,
|
||||
chartTopMargin,
|
||||
drawingHeight,
|
||||
xAxisTitleY,
|
||||
innerHeight,
|
||||
titleAreaStyle,
|
||||
titleVisualStyle,
|
||||
|
||||
@@ -7,6 +7,7 @@ import type {
|
||||
WaveformInteractionMode,
|
||||
WaveformLegendOptions,
|
||||
WaveformOverlayMode,
|
||||
WaveformPlotMargin,
|
||||
WaveformPoint,
|
||||
WaveformRenderingOptions,
|
||||
WaveformTitleOptions,
|
||||
@@ -31,6 +32,8 @@ export interface WaveformChartProps {
|
||||
minVisiblePoints?: number
|
||||
initialXDomain?: [number, number]
|
||||
initialXDomains?: Record<string, [number, number]>
|
||||
yDomain?: [number, number]
|
||||
yDomains?: Record<string, [number, number]>
|
||||
timeUnit?: 's' | 'ms'
|
||||
frameNumber?: string | number
|
||||
frameStyle?: WaveformFrameStyle
|
||||
@@ -40,11 +43,13 @@ export interface WaveformChartProps {
|
||||
interactionMode?: WaveformInteractionMode
|
||||
grid?: WaveformGridOptions
|
||||
rendering?: WaveformRenderingOptions
|
||||
plotMargin?: WaveformPlotMargin
|
||||
title?: WaveformTitleOptions
|
||||
legend?: WaveformLegendOptions
|
||||
hiddenSeriesIds?: string[]
|
||||
defaultHiddenSeriesIds?: string[]
|
||||
cleanView?: boolean
|
||||
presentationMode?: boolean
|
||||
zeroLine?: WaveformZeroLineOptions
|
||||
}
|
||||
|
||||
@@ -62,9 +67,11 @@ type DefaultedProp =
|
||||
| 'annotationsVisible'
|
||||
| 'grid'
|
||||
| 'rendering'
|
||||
| 'plotMargin'
|
||||
| 'legend'
|
||||
| 'defaultHiddenSeriesIds'
|
||||
| 'cleanView'
|
||||
| 'presentationMode'
|
||||
| 'zeroLine'
|
||||
|
||||
export type ResolvedWaveformChartProps = Readonly<
|
||||
@@ -88,16 +95,17 @@ export interface WaveformChartEmit {
|
||||
(event: 'page-change', page: number, pageCount: number): void
|
||||
}
|
||||
|
||||
export interface ViewportSelectionState {
|
||||
interface ViewportSelectionBase {
|
||||
trackIndex: number
|
||||
independent: boolean
|
||||
overlay: SVGRectElement
|
||||
startX: number
|
||||
startY: number
|
||||
currentX: number
|
||||
currentY: number
|
||||
pointerId: number
|
||||
mode: 'box' | 'pan'
|
||||
xDomain: [number, number]
|
||||
yDomains: Record<string, [number, number]>
|
||||
}
|
||||
|
||||
export type ViewportSelectionState =
|
||||
(ViewportSelectionBase & { kind: 'box' }) | (ViewportSelectionBase & { kind: 'pan' })
|
||||
|
||||
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])),
|
||||
]
|
||||
}
|
||||
@@ -13,6 +13,7 @@ export type {
|
||||
WaveformAnnotationStyle,
|
||||
WaveformAnnotation,
|
||||
WaveformRenderingOptions,
|
||||
WaveformPlotMargin,
|
||||
WaveformTitleTextStyle,
|
||||
WaveformTitleOptions,
|
||||
WaveformLegendPosition,
|
||||
|
||||
@@ -11,6 +11,7 @@ export type {
|
||||
WaveformAnnotationStyle,
|
||||
WaveformAnnotation,
|
||||
WaveformRenderingOptions,
|
||||
WaveformPlotMargin,
|
||||
WaveformTitleTextStyle,
|
||||
WaveformTitleOptions,
|
||||
WaveformLegendPosition,
|
||||
|
||||
@@ -38,9 +38,48 @@ describe('WaveformTooltip', () => {
|
||||
const tooltip = mountTooltip(100, 200).get('.waveform-tooltip')
|
||||
|
||||
expect(tooltip.attributes('style')).toContain('left: 8px')
|
||||
expect(tooltip.attributes('style')).toContain('max-width: 184px')
|
||||
expect(tooltip.attributes('style')).not.toContain('right:')
|
||||
})
|
||||
|
||||
it('keeps short content content-sized while exposing the available width cap', () => {
|
||||
const tooltip = mountTooltip(100).get('.waveform-tooltip')
|
||||
|
||||
expect(tooltip.attributes('style')).toContain('left: 112px')
|
||||
expect(tooltip.attributes('style')).toContain('max-width: 280px')
|
||||
})
|
||||
|
||||
it('keeps long series content in a wrapping content container', () => {
|
||||
const longName = 'ENG8KJXAc-very-long-series-name-10001'
|
||||
const pointWithErrors = { x: 1, y: 12, error: 1, upperError: 2 }
|
||||
const wrapper = mount(WaveformTooltip, {
|
||||
props: {
|
||||
visible: true,
|
||||
position: { x: 100, y: 100 },
|
||||
timeUnit: 'ms',
|
||||
hoveredPoint: pointWithErrors,
|
||||
seriesPoints: [
|
||||
{
|
||||
trackIndex: 0,
|
||||
name: longName,
|
||||
color: '#f00',
|
||||
unit: 'very-long-unit',
|
||||
point: pointWithErrors,
|
||||
},
|
||||
],
|
||||
containerWidth: 400,
|
||||
containerHeight: 300,
|
||||
},
|
||||
})
|
||||
|
||||
const tooltip = wrapper.get('.waveform-tooltip')
|
||||
expect(tooltip.get('.waveform-tooltip__series-content').text()).toContain(longName)
|
||||
expect(tooltip.get('.waveform-tooltip__series-content').classes()).toContain(
|
||||
'waveform-tooltip__series-content',
|
||||
)
|
||||
expect(tooltip.attributes('style')).toContain('max-width: 280px')
|
||||
})
|
||||
|
||||
it('shows resolved asymmetric errors beside the hovered value', () => {
|
||||
const pointWithErrors = { x: 1, y: 12, error: 1, upperError: 2 }
|
||||
const wrapper = mount(WaveformTooltip, {
|
||||
@@ -91,6 +130,22 @@ describe('WaveformTooltip', () => {
|
||||
expect(wrapper.get('.waveform-tooltip__value').text()).toBe('-1,405.4932 A')
|
||||
})
|
||||
|
||||
it('formats tooltip time with at most four decimal places', () => {
|
||||
const wrapper = mount(WaveformTooltip, {
|
||||
props: {
|
||||
visible: true,
|
||||
position: { x: 10, y: 10 },
|
||||
timeUnit: 's',
|
||||
hoveredPoint: { x: 1.234567, y: 12 },
|
||||
seriesPoints: [],
|
||||
containerWidth: 400,
|
||||
containerHeight: 300,
|
||||
},
|
||||
})
|
||||
|
||||
expect(wrapper.get('.waveform-tooltip__time').text()).toBe('s: 1.2346')
|
||||
})
|
||||
|
||||
it('omits the error label when both resolved errors are zero', () => {
|
||||
const point = { x: 1, y: 12 }
|
||||
const wrapper = mount(WaveformTooltip, {
|
||||
|
||||
@@ -33,24 +33,68 @@ const props = defineProps<Props>()
|
||||
|
||||
const tooltipGap = 12
|
||||
const containerPadding = 8
|
||||
const tooltipMaxWidth = 238
|
||||
const tooltipPlacementWidth = 238
|
||||
const tooltipMaxWidth = 320
|
||||
const tooltipHorizontalPadding = 20
|
||||
const tooltipLineHeight = 20
|
||||
|
||||
function estimateLineCount(text: string, width: number): number {
|
||||
const contentWidth = Math.max(1, width - tooltipHorizontalPadding - 14)
|
||||
const charactersPerLine = Math.max(1, Math.floor(contentWidth / 7.2))
|
||||
return Math.max(1, Math.ceil([...text].length / charactersPerLine))
|
||||
}
|
||||
|
||||
function formatSeriesText(seriesPoint: SeriesPoint): string {
|
||||
const error = formatError(seriesPoint.point)
|
||||
return `${seriesPoint.name ? `${seriesPoint.name}: ` : ''}${formatTooltipNumber(seriesPoint.point.y)}${
|
||||
seriesPoint.unit ? ` ${seriesPoint.unit}` : ''
|
||||
}${error ? ` ${error}` : ''}`
|
||||
}
|
||||
|
||||
function estimateTooltipHeight(width: number, timeText: string): number {
|
||||
const seriesLines = props.seriesPoints.reduce(
|
||||
(total, seriesPoint) => total + estimateLineCount(formatSeriesText(seriesPoint), width),
|
||||
0,
|
||||
)
|
||||
return 16 + tooltipLineHeight * (estimateLineCount(timeText, width) + seriesLines) + 5
|
||||
}
|
||||
|
||||
const tooltipStyle = computed(() => {
|
||||
if (!props.visible || !props.hoveredPoint) return { display: 'none' }
|
||||
|
||||
const estimatedHeight = 44 + props.seriesPoints.length * 22
|
||||
const rightPlacement = props.position.x + tooltipGap
|
||||
const leftPlacement = props.position.x - tooltipGap - tooltipMaxWidth
|
||||
const leftPlacement = props.position.x - tooltipGap - tooltipPlacementWidth
|
||||
const rightAvailableWidth = props.containerWidth - containerPadding - rightPlacement
|
||||
const leftAvailableWidth = props.position.x - tooltipGap - containerPadding
|
||||
const availableWidth = Math.max(
|
||||
1,
|
||||
Math.min(tooltipMaxWidth, props.containerWidth - containerPadding * 2),
|
||||
)
|
||||
const horizontalStyle =
|
||||
rightPlacement + tooltipMaxWidth <= props.containerWidth - containerPadding
|
||||
? { left: `${rightPlacement}px` }
|
||||
rightPlacement + tooltipPlacementWidth <= props.containerWidth - containerPadding
|
||||
? {
|
||||
left: `${rightPlacement}px`,
|
||||
maxWidth: `${Math.min(tooltipMaxWidth, rightAvailableWidth)}px`,
|
||||
}
|
||||
: leftPlacement >= containerPadding
|
||||
? { right: `${props.containerWidth - props.position.x + tooltipGap}px` }
|
||||
: { left: `${containerPadding}px` }
|
||||
? {
|
||||
right: `${props.containerWidth - props.position.x + tooltipGap}px`,
|
||||
maxWidth: `${Math.min(tooltipMaxWidth, leftAvailableWidth)}px`,
|
||||
}
|
||||
: { left: `${containerPadding}px`, maxWidth: `${availableWidth}px` }
|
||||
|
||||
const maxWidth = Number.parseFloat(horizontalStyle.maxWidth)
|
||||
const timeText = `${props.timeUnit}: ${formatTooltipTime(props.hoveredPoint.x, props.timeUnit)}`
|
||||
|
||||
return {
|
||||
...horizontalStyle,
|
||||
top: `${Math.max(8, Math.min(props.position.y - 18, props.containerHeight - estimatedHeight - 8))}px`,
|
||||
top: `${Math.max(
|
||||
8,
|
||||
Math.min(
|
||||
props.position.y - 18,
|
||||
props.containerHeight - estimateTooltipHeight(maxWidth, timeText) - 8,
|
||||
),
|
||||
)}px`,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -76,11 +120,13 @@ function formatError(point: WaveformPoint): string | null {
|
||||
class="waveform-tooltip__series waveform-chart__tooltip-series"
|
||||
>
|
||||
<i :style="{ backgroundColor: seriesPoint.color }" />
|
||||
<strong v-if="seriesPoint.name">{{ seriesPoint.name }}:</strong>
|
||||
<span class="waveform-tooltip__value">
|
||||
{{ formatTooltipNumber(seriesPoint.point.y)
|
||||
}}{{ seriesPoint.unit ? ` ${seriesPoint.unit}` : '' }}
|
||||
<small v-if="formatError(seriesPoint.point)">{{ formatError(seriesPoint.point) }}</small>
|
||||
<span class="waveform-tooltip__series-content">
|
||||
<strong v-if="seriesPoint.name">{{ seriesPoint.name }}:</strong>
|
||||
<span class="waveform-tooltip__value">
|
||||
{{ formatTooltipNumber(seriesPoint.point.y)
|
||||
}}{{ seriesPoint.unit ? ` ${seriesPoint.unit}` : '' }}
|
||||
<small v-if="formatError(seriesPoint.point)">{{ formatError(seriesPoint.point) }}</small>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
@@ -93,8 +139,8 @@ function formatError(point: WaveformPoint): string | null {
|
||||
z-index: 2;
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 180px;
|
||||
max-width: 238px;
|
||||
width: max-content;
|
||||
max-width: min(320px, calc(100% - 16px));
|
||||
padding: 8px 10px;
|
||||
color: #333;
|
||||
font:
|
||||
@@ -116,30 +162,34 @@ function formatError(point: WaveformPoint): string | null {
|
||||
|
||||
.waveform-tooltip__series {
|
||||
display: grid;
|
||||
grid-template-columns: 8px minmax(0, 1fr) auto;
|
||||
grid-template-columns: 8px minmax(0, 1fr);
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.waveform-tooltip__series i {
|
||||
flex: 0 0 auto;
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 4px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.waveform-tooltip__series-content {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.waveform-tooltip__series strong {
|
||||
overflow: hidden;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.waveform-tooltip__value {
|
||||
white-space: nowrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.waveform-tooltip__series small {
|
||||
color: #667085;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
10
src/components/interaction/pointerCapture.ts
Normal file
10
src/components/interaction/pointerCapture.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export function tryReleasePointerCapture(
|
||||
target: SVGRectElement | null | undefined,
|
||||
pointerId: number,
|
||||
): void {
|
||||
try {
|
||||
target?.releasePointerCapture?.(pointerId)
|
||||
} catch {
|
||||
// The target may already be detached or have released the pointer.
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ interface HoverContext {
|
||||
titleAreaHeight: ComputedRef<number>
|
||||
chartTopMargin: ComputedRef<number>
|
||||
sharedOverlayElement: ShallowRef<SVGRectElement | undefined>
|
||||
isPresentationMode: ComputedRef<boolean>
|
||||
updateViewportDrag: (event: PointerEvent) => void
|
||||
resolveTrackAtPointer: (pointerX: number, pointerY: number) => TrackLayout | undefined
|
||||
}
|
||||
@@ -42,6 +43,7 @@ export function useWaveformHover(context: HoverContext) {
|
||||
titleAreaHeight,
|
||||
chartTopMargin,
|
||||
sharedOverlayElement,
|
||||
isPresentationMode,
|
||||
updateViewportDrag,
|
||||
resolveTrackAtPointer,
|
||||
} = context
|
||||
@@ -62,6 +64,7 @@ export function useWaveformHover(context: HoverContext) {
|
||||
trackIndex: number | null,
|
||||
position: { x: number; y: number },
|
||||
) => {
|
||||
if (isPresentationMode.value) return
|
||||
if (!hoveredPointsMatch(nextPoints)) hoverState.points = nextPoints
|
||||
hoverState.trackIndex = trackIndex
|
||||
hoverState.position = position
|
||||
@@ -73,6 +76,9 @@ export function useWaveformHover(context: HoverContext) {
|
||||
hoverState.trackIndex = null
|
||||
emit('point-hover', null)
|
||||
}
|
||||
const handlePointerLeave = () => {
|
||||
if (!isPresentationMode.value) clearHover()
|
||||
}
|
||||
const createHoveredSeriesPoint = (
|
||||
series: DisplaySeries,
|
||||
trackIndex: number,
|
||||
@@ -103,6 +109,7 @@ export function useWaveformHover(context: HoverContext) {
|
||||
series.points[pointBisector.center(series.points, xValue)]
|
||||
|
||||
const handleIndependentPointerMove = (event: PointerEvent, trackIndex: number) => {
|
||||
if (isPresentationMode.value) return
|
||||
if (selection.value?.trackIndex === trackIndex && selection.value.independent) {
|
||||
updateViewportDrag(event)
|
||||
return
|
||||
@@ -128,7 +135,8 @@ export function useWaveformHover(context: HoverContext) {
|
||||
})
|
||||
}
|
||||
const handleSharedPointerMove = (event: PointerEvent) => {
|
||||
if (selection.value?.overlay === event.currentTarget) {
|
||||
if (isPresentationMode.value) return
|
||||
if (selection.value && !selection.value.independent) {
|
||||
updateViewportDrag(event)
|
||||
return
|
||||
}
|
||||
@@ -161,6 +169,7 @@ export function useWaveformHover(context: HoverContext) {
|
||||
return {
|
||||
cancelPendingHover,
|
||||
clearHover,
|
||||
handlePointerLeave,
|
||||
beginAnnotationDrag,
|
||||
endAnnotationDrag,
|
||||
handleIndependentPointerMove,
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
import { pointer, scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
|
||||
import { computed, nextTick, type ComputedRef, type Ref, type ShallowRef } from 'vue'
|
||||
|
||||
import { pointer, zoomIdentity, type ZoomTransform } from 'd3'
|
||||
import { computed, nextTick, shallowRef, type ComputedRef, type Ref, type ShallowRef } from 'vue'
|
||||
import { MINIMUM_SELECTION_SIZE } from '../core/constants'
|
||||
import type { DisplayTrack, TrackLayout } from '../core/types'
|
||||
import { hasFixedYDomainForTrack } from '../core/yDomain'
|
||||
import type {
|
||||
ResolvedWaveformChartProps,
|
||||
ViewportSelectionState,
|
||||
WaveformChartEmit,
|
||||
} from '../core/waveformChartTypes'
|
||||
import type { AnnotationSeriesCandidate } from '../annotation'
|
||||
|
||||
import { tryReleasePointerCapture } from './pointerCapture'
|
||||
import { transitionViewportInteraction } from './viewportInteractionState'
|
||||
import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
|
||||
interface ViewportContext {
|
||||
props: ResolvedWaveformChartProps
|
||||
emit: WaveformChartEmit
|
||||
@@ -26,6 +28,7 @@ interface ViewportContext {
|
||||
sharedYDomains: Ref<Record<string, [number, number]>>
|
||||
independentYDomains: Ref<Record<number, [number, number]>>
|
||||
isZoomMode: ComputedRef<boolean>
|
||||
isPresentationMode: ComputedRef<boolean>
|
||||
editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
|
||||
resolveInitialTrackDomain: (track: TrackLayout) => [number, number]
|
||||
canZoomTrack: (track: TrackLayout) => boolean
|
||||
@@ -35,7 +38,6 @@ interface ViewportContext {
|
||||
clearHover: () => void
|
||||
resolveTrackAtPointer: (pointerX: number, pointerY: number) => TrackLayout | undefined
|
||||
}
|
||||
|
||||
export function useWaveformViewport(context: ViewportContext) {
|
||||
const {
|
||||
props,
|
||||
@@ -53,6 +55,7 @@ export function useWaveformViewport(context: ViewportContext) {
|
||||
sharedYDomains,
|
||||
independentYDomains,
|
||||
isZoomMode,
|
||||
isPresentationMode,
|
||||
editorSeriesOptions,
|
||||
resolveInitialTrackDomain,
|
||||
canZoomTrack,
|
||||
@@ -62,6 +65,23 @@ export function useWaveformViewport(context: ViewportContext) {
|
||||
clearHover,
|
||||
resolveTrackAtPointer,
|
||||
} = context
|
||||
const activeOverlay = shallowRef<SVGRectElement>()
|
||||
const releasePointerCapture = (pointerId: number, event?: PointerEvent) => {
|
||||
const overlay = activeOverlay.value
|
||||
const eventTarget = event?.currentTarget as SVGRectElement | null
|
||||
tryReleasePointerCapture(overlay, pointerId)
|
||||
if (eventTarget && eventTarget !== overlay) tryReleasePointerCapture(eventTarget, pointerId)
|
||||
}
|
||||
const cleanupViewportDrag = (pointerId: number, event?: PointerEvent) => {
|
||||
const active = selection.value
|
||||
if (!active || active.pointerId !== pointerId) return
|
||||
releasePointerCapture(pointerId, event)
|
||||
selection.value = transitionViewportInteraction(selection.value, {
|
||||
type: 'cancel',
|
||||
pointerId,
|
||||
}).state
|
||||
activeOverlay.value = undefined
|
||||
}
|
||||
const selectionBox = computed(() => {
|
||||
const active = selection.value
|
||||
if (!active) return null
|
||||
@@ -73,48 +93,6 @@ export function useWaveformViewport(context: ViewportContext) {
|
||||
height: Math.abs(active.currentY - active.startY),
|
||||
}
|
||||
})
|
||||
const 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)
|
||||
}
|
||||
const 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
|
||||
}
|
||||
const 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]
|
||||
}
|
||||
const clampDomain = (domain: [number, number], boundary: [number, number]): [number, number] => {
|
||||
const span = domain[1] - domain[0]
|
||||
const boundarySpan = boundary[1] - boundary[0]
|
||||
@@ -131,6 +109,7 @@ export function useWaveformViewport(context: ViewportContext) {
|
||||
]),
|
||||
)
|
||||
const beginViewportDrag = (event: PointerEvent, trackIndex: number, independent: boolean) => {
|
||||
if (isPresentationMode.value) return
|
||||
const panRequested = props.pannable && spacePressed.value
|
||||
if ((!props.zoomable && !panRequested) || !isZoomMode.value || event.button !== 0) return
|
||||
const overlay = event.currentTarget as SVGRectElement
|
||||
@@ -139,19 +118,22 @@ export function useWaveformViewport(context: ViewportContext) {
|
||||
const [rawX, rawY] = pointer(event, overlay)
|
||||
const x = Math.max(0, Math.min(independent ? track.width : innerWidth.value, rawX))
|
||||
const y = Math.max(0, Math.min(independent ? track.height : innerHeight.value, rawY))
|
||||
selection.value = {
|
||||
trackIndex,
|
||||
independent,
|
||||
overlay,
|
||||
startX: x,
|
||||
startY: y,
|
||||
currentX: x,
|
||||
currentY: y,
|
||||
pointerId: event.pointerId,
|
||||
mode: panRequested ? 'pan' : 'box',
|
||||
xDomain: track.xScale.domain() as [number, number],
|
||||
yDomains: currentYDomains(),
|
||||
}
|
||||
const started = transitionViewportInteraction(selection.value, {
|
||||
type: 'begin',
|
||||
gesture: {
|
||||
trackIndex,
|
||||
independent,
|
||||
startX: x,
|
||||
startY: y,
|
||||
pointerId: event.pointerId,
|
||||
kind: panRequested ? 'pan' : 'box',
|
||||
xDomain: track.xScale.domain() as [number, number],
|
||||
yDomains: currentYDomains(),
|
||||
},
|
||||
})
|
||||
if (!started.accepted) return
|
||||
selection.value = started.state
|
||||
activeOverlay.value = overlay
|
||||
overlay.setPointerCapture?.(event.pointerId)
|
||||
clearHover()
|
||||
event.preventDefault()
|
||||
@@ -187,9 +169,13 @@ export function useWaveformViewport(context: ViewportContext) {
|
||||
const nextIndependentDomains = { ...independentYDomains.value }
|
||||
const nextSharedDomains = { ...sharedYDomains.value }
|
||||
targets.forEach((target) => {
|
||||
const chartTrack = chartTracks.value.find((item) => item.id === target.id)
|
||||
if (chartTrack && hasFixedYDomainForTrack(chartTrack, props.yDomain, props.yDomains)) {
|
||||
return
|
||||
}
|
||||
const key = target.series.trackId ?? target.series.id
|
||||
const source = active.yDomains[key] ?? (target.yScale.domain() as [number, number])
|
||||
const boundary = chartTracks.value.find((item) => item.id === key)?.yDomain ?? source
|
||||
const boundary = chartTrack?.yDomain ?? source
|
||||
const ySpan = source[1] - source[0]
|
||||
const nextY = clampDomain(
|
||||
[source[0] + (dy / height) * ySpan, source[1] + (dy / height) * ySpan],
|
||||
@@ -203,28 +189,36 @@ export function useWaveformViewport(context: ViewportContext) {
|
||||
emit('zoom-change', nextX)
|
||||
}
|
||||
const updateViewportDrag = (event: PointerEvent) => {
|
||||
if (isPresentationMode.value) return
|
||||
const active = selection.value
|
||||
if (!active || event.pointerId !== active.pointerId) return
|
||||
const overlay = activeOverlay.value
|
||||
if (!active || !overlay || event.pointerId !== active.pointerId) return
|
||||
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
|
||||
if (!track) return
|
||||
const [rawX, rawY] = pointer(event, active.overlay)
|
||||
active.currentX = Math.max(
|
||||
const [rawX, rawY] = pointer(event, overlay)
|
||||
const currentX = Math.max(
|
||||
0,
|
||||
Math.min(active.independent ? track.width : innerWidth.value, rawX),
|
||||
)
|
||||
active.currentY = Math.max(
|
||||
const currentY = Math.max(
|
||||
0,
|
||||
Math.min(active.independent ? track.height : innerHeight.value, rawY),
|
||||
)
|
||||
selection.value = { ...active }
|
||||
if (active.mode === 'pan') applyPan(active, track)
|
||||
const transition = transitionViewportInteraction(selection.value, {
|
||||
type: 'move',
|
||||
pointerId: event.pointerId,
|
||||
position: { currentX, currentY },
|
||||
})
|
||||
if (!transition.accepted || !transition.state) return
|
||||
const next = transition.state
|
||||
selection.value = next
|
||||
if (next.kind === 'pan') applyPan(next, track)
|
||||
event.preventDefault()
|
||||
}
|
||||
const cancelViewportDrag = (event?: PointerEvent) => {
|
||||
const active = selection.value
|
||||
if (!active || (event && event.pointerId !== active.pointerId)) return
|
||||
active.overlay.releasePointerCapture?.(active.pointerId)
|
||||
selection.value = null
|
||||
cleanupViewportDrag(active.pointerId, event)
|
||||
}
|
||||
const applyBoxZoom = (active: ViewportSelectionState) => {
|
||||
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
|
||||
@@ -240,9 +234,14 @@ export function useWaveformViewport(context: ViewportContext) {
|
||||
)
|
||||
if (right - left < MINIMUM_SELECTION_SIZE) return
|
||||
const baseXDomain = active.independent ? resolveInitialTrackDomain(track) : initialXDomain.value
|
||||
const groups = active.independent
|
||||
? [track.seriesList]
|
||||
: trackLayouts.value.filter((item) => item.hasVisibleSeries).map((item) => item.seriesList)
|
||||
const xDomain = constrainZoomDomain(
|
||||
[track.xScale.invert(left), track.xScale.invert(right)],
|
||||
baseXDomain,
|
||||
groups,
|
||||
props,
|
||||
)
|
||||
if (active.independent) {
|
||||
const next = [...independentTransforms.value]
|
||||
@@ -285,17 +284,44 @@ export function useWaveformViewport(context: ViewportContext) {
|
||||
void nextTick(configureZoom)
|
||||
}
|
||||
const finishViewportDrag = (event: PointerEvent) => {
|
||||
if (isPresentationMode.value) {
|
||||
cancelViewportDrag()
|
||||
return
|
||||
}
|
||||
const active = selection.value
|
||||
if (!active || event.pointerId !== active.pointerId) return
|
||||
updateViewportDrag(event)
|
||||
active.overlay.releasePointerCapture?.(active.pointerId)
|
||||
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
|
||||
const overlay = activeOverlay.value
|
||||
if (!track || !overlay || !overlay.parentNode) {
|
||||
cleanupViewportDrag(active.pointerId, event)
|
||||
return
|
||||
}
|
||||
const [rawX, rawY] = pointer(event, overlay)
|
||||
const currentX = Math.max(
|
||||
0,
|
||||
Math.min(active.independent ? track.width : innerWidth.value, rawX),
|
||||
)
|
||||
const currentY = Math.max(
|
||||
0,
|
||||
Math.min(active.independent ? track.height : innerHeight.value, rawY),
|
||||
)
|
||||
const completed = transitionViewportInteraction(selection.value, {
|
||||
type: 'finish',
|
||||
pointerId: event.pointerId,
|
||||
position: { currentX, currentY },
|
||||
}).completed
|
||||
if (!completed) return
|
||||
selection.value = null
|
||||
if (active.mode === 'pan') {
|
||||
releasePointerCapture(completed.pointerId, event)
|
||||
activeOverlay.value = undefined
|
||||
event.preventDefault()
|
||||
if (completed.kind === 'pan') {
|
||||
applyPan(completed, track)
|
||||
void nextTick(configureZoom)
|
||||
return
|
||||
}
|
||||
if (Math.abs(active.currentX - active.startX) >= MINIMUM_SELECTION_SIZE) {
|
||||
applyBoxZoom(active)
|
||||
if (Math.abs(completed.currentX - completed.startX) >= MINIMUM_SELECTION_SIZE) {
|
||||
applyBoxZoom(completed)
|
||||
}
|
||||
}
|
||||
const resetViewport = (trackIndex?: number) => {
|
||||
@@ -319,12 +345,11 @@ export function useWaveformViewport(context: ViewportContext) {
|
||||
void nextTick(configureZoom)
|
||||
}
|
||||
const requestViewportReset = (event: MouseEvent) => {
|
||||
if (!props.zoomable || !isZoomMode.value) return
|
||||
if (isPresentationMode.value || !props.zoomable || !isZoomMode.value) return
|
||||
event.preventDefault()
|
||||
resetViewport()
|
||||
emit('zoom-reset')
|
||||
}
|
||||
|
||||
return {
|
||||
selectionBox,
|
||||
beginViewportDrag,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
select,
|
||||
zoom,
|
||||
zoomIdentity,
|
||||
zoomTransform,
|
||||
type D3ZoomEvent,
|
||||
type ZoomBehavior,
|
||||
type ZoomTransform,
|
||||
@@ -14,6 +15,12 @@ import { WHEEL_ZOOM_DEBOUNCE_MS, ZOOM_CONSTRAINTS } from '../core/constants'
|
||||
import type { TrackLayout } from '../core/types'
|
||||
import type { ResolvedWaveformChartProps, WaveformChartEmit } from '../core/waveformChartTypes'
|
||||
import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
|
||||
import {
|
||||
constrainZoomDomain,
|
||||
resolveMinimumZoomSpan,
|
||||
transformForDomain,
|
||||
type ZoomSeriesGroup,
|
||||
} from './zoomConstraints'
|
||||
|
||||
interface ZoomContext {
|
||||
props: ResolvedWaveformChartProps
|
||||
@@ -29,6 +36,7 @@ interface ZoomContext {
|
||||
innerHeight: ComputedRef<number>
|
||||
hasChartArea: ComputedRef<boolean>
|
||||
isZoomMode: ComputedRef<boolean>
|
||||
isPresentationMode: ComputedRef<boolean>
|
||||
resolveInitialTrackDomain: (track: TrackLayout) => [number, number]
|
||||
cancelPendingHover: () => void
|
||||
}
|
||||
@@ -50,6 +58,7 @@ export function useWaveformZoom(context: ZoomContext) {
|
||||
innerHeight,
|
||||
hasChartArea,
|
||||
isZoomMode,
|
||||
isPresentationMode,
|
||||
resolveInitialTrackDomain,
|
||||
cancelPendingHover,
|
||||
} = context
|
||||
@@ -67,7 +76,7 @@ export function useWaveformZoom(context: ZoomContext) {
|
||||
|
||||
const scheduleZoomCommit = () => zoomThrottle.schedule(commitPendingZoom)
|
||||
const handleSharedZoom = (event: D3ZoomEvent<SVGRectElement, unknown>) => {
|
||||
if (synchronizingZoomTransform) return
|
||||
if (synchronizingZoomTransform || isPresentationMode.value) return
|
||||
cancelPendingHover()
|
||||
pendingSharedZoomTransform = event.transform
|
||||
pendingSharedZoomGesture = 'wheel'
|
||||
@@ -78,7 +87,7 @@ export function useWaveformZoom(context: ZoomContext) {
|
||||
event: D3ZoomEvent<SVGRectElement, unknown>,
|
||||
trackIndex: number,
|
||||
) => {
|
||||
if (synchronizingZoomTransform) return
|
||||
if (synchronizingZoomTransform || isPresentationMode.value) return
|
||||
cancelPendingHover()
|
||||
pendingIndependentZoomTransforms.set(trackIndex, event.transform)
|
||||
pendingIndependentZoomGestures.set(trackIndex, 'wheel')
|
||||
@@ -86,6 +95,10 @@ export function useWaveformZoom(context: ZoomContext) {
|
||||
scheduleWheelZoomEnd()
|
||||
}
|
||||
function commitPendingZoom() {
|
||||
if (isPresentationMode.value) {
|
||||
cancelPendingZoom()
|
||||
return
|
||||
}
|
||||
if (pendingSharedZoomTransform) {
|
||||
const transform = pendingSharedZoomTransform
|
||||
pendingSharedZoomTransform = null
|
||||
@@ -209,31 +222,60 @@ export function useWaveformZoom(context: ZoomContext) {
|
||||
.forEach((overlay) => select(overlay).on('.zoom', null))
|
||||
zoomBehaviors.clear()
|
||||
}
|
||||
const resolveMaximumZoomScale = (domain: [number, number]): number => {
|
||||
const minZoomSpan = props.minZoomSpan
|
||||
if (!Number.isFinite(minZoomSpan) || (minZoomSpan ?? 0) <= 0) {
|
||||
return ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE
|
||||
}
|
||||
const resolveMaximumZoomScale = (
|
||||
domain: [number, number],
|
||||
groups: readonly ZoomSeriesGroup[],
|
||||
): number => {
|
||||
const domainSpan = Math.abs(domain[1] - domain[0])
|
||||
if (!Number.isFinite(domainSpan) || domainSpan <= 0) return ZOOM_CONSTRAINTS.MIN_SCALE
|
||||
return Math.min(
|
||||
ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE,
|
||||
Math.max(ZOOM_CONSTRAINTS.MIN_SCALE, domainSpan / (minZoomSpan ?? domainSpan)),
|
||||
const minimumSpan = resolveMinimumZoomSpan(domain, groups, props)
|
||||
return Math.max(
|
||||
ZOOM_CONSTRAINTS.MIN_SCALE,
|
||||
minimumSpan > 0 ? domainSpan / minimumSpan : ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE,
|
||||
)
|
||||
}
|
||||
const canZoomTrack = (track: TrackLayout): boolean =>
|
||||
hasMinimumVisibleXValues(
|
||||
const constrainTransform = (
|
||||
transform: ZoomTransform,
|
||||
domain: [number, number],
|
||||
width: number,
|
||||
groups: readonly ZoomSeriesGroup[],
|
||||
): ZoomTransform => {
|
||||
const requested = transform.rescaleX(scaleLinear(domain, [0, width])).domain() as [
|
||||
number,
|
||||
number,
|
||||
]
|
||||
return transformForDomain(constrainZoomDomain(requested, domain, groups, props), domain, width)
|
||||
}
|
||||
const canZoomTrack = (track: TrackLayout): boolean => {
|
||||
const minimum = Number(props.minVisiblePoints)
|
||||
return hasMinimumVisibleXValues(
|
||||
track.seriesList,
|
||||
track.xScale.domain() as [number, number],
|
||||
Number(props.minVisiblePoints),
|
||||
Number.isFinite(minimum) && minimum > 0 ? Math.ceil(minimum) + 1 : minimum,
|
||||
)
|
||||
}
|
||||
const canZoomSharedTracks = (): boolean => {
|
||||
const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
|
||||
return tracks.length > 0 && tracks.every(canZoomTrack)
|
||||
}
|
||||
const canHandleWheelZoom = (event: Event, canZoomIn: boolean): boolean => {
|
||||
if (event.type !== 'wheel') return false
|
||||
const deltaY = (event as WheelEvent).deltaY
|
||||
if (deltaY < 0) return canZoomIn
|
||||
if (deltaY > 0) {
|
||||
return zoomTransform(event.currentTarget as Element).k > ZOOM_CONSTRAINTS.MIN_SCALE
|
||||
}
|
||||
return false
|
||||
}
|
||||
const configureZoom = () => {
|
||||
clearZoomBindings()
|
||||
if (!props.zoomable || !isZoomMode.value || !hasChartArea.value || !trackLayouts.value.length) {
|
||||
if (
|
||||
isPresentationMode.value ||
|
||||
!props.zoomable ||
|
||||
!isZoomMode.value ||
|
||||
!hasChartArea.value ||
|
||||
!trackLayouts.value.length
|
||||
) {
|
||||
return
|
||||
}
|
||||
if (props.displayMode === 'independent') {
|
||||
@@ -243,12 +285,15 @@ export function useWaveformZoom(context: ZoomContext) {
|
||||
)
|
||||
if (!overlay) return
|
||||
const dataDomain = resolveInitialTrackDomain(track)
|
||||
const groups = [track.seriesList]
|
||||
const behavior = zoom<SVGRectElement, unknown>()
|
||||
.filter(
|
||||
(event) =>
|
||||
event.type === 'wheel' && (event as WheelEvent).deltaY < 0 && canZoomTrack(track),
|
||||
)
|
||||
.scaleExtent([1, resolveMaximumZoomScale(dataDomain)])
|
||||
.filter((event) => {
|
||||
const currentTrack =
|
||||
trackLayouts.value.find((item) => item.index === track.index) ?? track
|
||||
return canHandleWheelZoom(event, canZoomTrack(currentTrack))
|
||||
})
|
||||
.scaleExtent([1, resolveMaximumZoomScale(dataDomain, groups)])
|
||||
.constrain((transform) => constrainTransform(transform, dataDomain, track.width, groups))
|
||||
.extent([
|
||||
[0, 0],
|
||||
[track.width, track.height],
|
||||
@@ -274,12 +319,15 @@ export function useWaveformZoom(context: ZoomContext) {
|
||||
}
|
||||
const overlay = sharedOverlayElement.value
|
||||
if (!overlay) return
|
||||
const groups = trackLayouts.value
|
||||
.filter((track) => track.hasVisibleSeries)
|
||||
.map((track) => track.seriesList)
|
||||
const behavior = zoom<SVGRectElement, unknown>()
|
||||
.filter(
|
||||
(event) =>
|
||||
event.type === 'wheel' && (event as WheelEvent).deltaY < 0 && canZoomSharedTracks(),
|
||||
.filter((event) => canHandleWheelZoom(event, canZoomSharedTracks()))
|
||||
.scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value, groups)])
|
||||
.constrain((transform) =>
|
||||
constrainTransform(transform, initialXDomain.value, innerWidth.value, groups),
|
||||
)
|
||||
.scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value)])
|
||||
.extent([
|
||||
[0, 0],
|
||||
[innerWidth.value, innerHeight.value],
|
||||
|
||||
101
src/components/interaction/viewportInteractionState.test.ts
Normal file
101
src/components/interaction/viewportInteractionState.test.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
reduceViewportInteraction,
|
||||
transitionViewportInteraction,
|
||||
type ViewportInteractionEvent,
|
||||
} from './viewportInteractionState'
|
||||
|
||||
const gesture = {
|
||||
trackIndex: 2,
|
||||
independent: true,
|
||||
startX: 10,
|
||||
startY: 20,
|
||||
pointerId: 7,
|
||||
kind: 'box' as const,
|
||||
xDomain: [0, 100] as [number, number],
|
||||
yDomains: { track: [-1, 1] as [number, number] },
|
||||
}
|
||||
|
||||
describe('viewport interaction reducer', () => {
|
||||
it('accepts one begin and rejects a conflicting begin', () => {
|
||||
const started = transitionViewportInteraction(null, { type: 'begin', gesture })
|
||||
const conflicting = transitionViewportInteraction(started.state, {
|
||||
type: 'begin',
|
||||
gesture: { ...gesture, pointerId: 8 },
|
||||
})
|
||||
|
||||
expect(started.accepted).toBe(true)
|
||||
expect(started.state).toMatchObject({ kind: 'box', pointerId: 7 })
|
||||
expect(conflicting.accepted).toBe(false)
|
||||
expect(conflicting.state).toMatchObject({ kind: 'box', pointerId: 7 })
|
||||
})
|
||||
|
||||
it('rejects move and finish from a different pointer without changing state', () => {
|
||||
const state = reduceViewportInteraction(null, { type: 'begin', gesture })
|
||||
const move = transitionViewportInteraction(state, {
|
||||
type: 'move',
|
||||
pointerId: 8,
|
||||
position: { currentX: 30, currentY: 40 },
|
||||
})
|
||||
const finish = transitionViewportInteraction(move.state, {
|
||||
type: 'finish',
|
||||
pointerId: 8,
|
||||
position: { currentX: 30, currentY: 40 },
|
||||
})
|
||||
|
||||
expect(move.accepted).toBe(false)
|
||||
expect(finish.accepted).toBe(false)
|
||||
expect(finish.state).toMatchObject({ currentX: 10, currentY: 20 })
|
||||
})
|
||||
|
||||
it('updates a valid pointer and returns the completed snapshot on finish', () => {
|
||||
const state = reduceViewportInteraction(null, {
|
||||
type: 'begin',
|
||||
gesture: { ...gesture, kind: 'pan' },
|
||||
})
|
||||
const moved = transitionViewportInteraction(state, {
|
||||
type: 'move',
|
||||
pointerId: 7,
|
||||
position: { currentX: 30, currentY: 40 },
|
||||
})
|
||||
const finished = transitionViewportInteraction(moved.state, {
|
||||
type: 'finish',
|
||||
pointerId: 7,
|
||||
position: { currentX: 50, currentY: 60 },
|
||||
})
|
||||
|
||||
expect(moved.state).toMatchObject({ kind: 'pan', currentX: 30, currentY: 40 })
|
||||
expect(finished.completed).toMatchObject({ kind: 'pan', currentX: 50, currentY: 60 })
|
||||
expect(finished.state).toBeNull()
|
||||
})
|
||||
|
||||
it('cancels only the owning pointer and supports cancel/reset events', () => {
|
||||
const state = reduceViewportInteraction(null, { type: 'begin', gesture })
|
||||
const rejectedCancel = transitionViewportInteraction(state, { type: 'cancel', pointerId: 8 })
|
||||
const cancelled = transitionViewportInteraction(rejectedCancel.state, {
|
||||
type: 'cancel',
|
||||
pointerId: 7,
|
||||
})
|
||||
const restarted = reduceViewportInteraction(null, { type: 'begin', gesture })
|
||||
const reset = transitionViewportInteraction(restarted, { type: 'reset' })
|
||||
|
||||
expect(rejectedCancel.accepted).toBe(false)
|
||||
expect(rejectedCancel.state).not.toBeNull()
|
||||
expect(cancelled.accepted).toBe(true)
|
||||
expect(cancelled.state).toBeNull()
|
||||
expect(reset.accepted).toBe(true)
|
||||
expect(reset.state).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps events discriminated and does not mutate the begin input', () => {
|
||||
const event: ViewportInteractionEvent = { type: 'begin', gesture }
|
||||
const state = reduceViewportInteraction(null, event)
|
||||
|
||||
expect(state).not.toBeNull()
|
||||
expect(state?.xDomain).toEqual([0, 100])
|
||||
expect(state?.yDomains.track).toEqual([-1, 1])
|
||||
expect(event.gesture.xDomain).toEqual([0, 100])
|
||||
expect(event.gesture.yDomains.track).toEqual([-1, 1])
|
||||
})
|
||||
})
|
||||
89
src/components/interaction/viewportInteractionState.ts
Normal file
89
src/components/interaction/viewportInteractionState.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
import type { ViewportSelectionState } from '../core/waveformChartTypes'
|
||||
|
||||
export interface ViewportGestureStart {
|
||||
trackIndex: number
|
||||
independent: boolean
|
||||
startX: number
|
||||
startY: number
|
||||
pointerId: number
|
||||
kind: 'box' | 'pan'
|
||||
xDomain: [number, number]
|
||||
yDomains: Record<string, [number, number]>
|
||||
}
|
||||
|
||||
export interface ViewportGesturePosition {
|
||||
currentX: number
|
||||
currentY: number
|
||||
}
|
||||
|
||||
export type ViewportInteractionEvent =
|
||||
| { type: 'begin'; gesture: ViewportGestureStart }
|
||||
| { type: 'move'; pointerId: number; position: ViewportGesturePosition }
|
||||
| { type: 'finish'; pointerId: number; position: ViewportGesturePosition }
|
||||
| { type: 'cancel'; pointerId?: number }
|
||||
| { type: 'reset' }
|
||||
|
||||
export interface ViewportInteractionTransition {
|
||||
state: ViewportSelectionState | null
|
||||
accepted: boolean
|
||||
completed: ViewportSelectionState | null
|
||||
}
|
||||
|
||||
function createSelectionState(input: ViewportGestureStart): ViewportSelectionState {
|
||||
return {
|
||||
...input,
|
||||
currentX: input.startX,
|
||||
currentY: input.startY,
|
||||
xDomain: [...input.xDomain],
|
||||
yDomains: Object.fromEntries(
|
||||
Object.entries(input.yDomains).map(([key, domain]) => [key, [...domain] as [number, number]]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
function withPosition(
|
||||
state: ViewportSelectionState,
|
||||
position: ViewportGesturePosition,
|
||||
): ViewportSelectionState {
|
||||
return { ...state, ...position }
|
||||
}
|
||||
|
||||
function rejectedTransition(state: ViewportSelectionState | null): ViewportInteractionTransition {
|
||||
return { state, accepted: false, completed: null }
|
||||
}
|
||||
|
||||
export function transitionViewportInteraction(
|
||||
state: ViewportSelectionState | null,
|
||||
event: ViewportInteractionEvent,
|
||||
): ViewportInteractionTransition {
|
||||
switch (event.type) {
|
||||
case 'begin':
|
||||
return state
|
||||
? rejectedTransition(state)
|
||||
: { state: createSelectionState(event.gesture), accepted: true, completed: null }
|
||||
case 'move':
|
||||
if (!state || state.pointerId !== event.pointerId) return rejectedTransition(state)
|
||||
return { state: withPosition(state, event.position), accepted: true, completed: null }
|
||||
case 'finish':
|
||||
if (!state || state.pointerId !== event.pointerId) return rejectedTransition(state)
|
||||
return {
|
||||
state: null,
|
||||
accepted: true,
|
||||
completed: withPosition(state, event.position),
|
||||
}
|
||||
case 'cancel':
|
||||
if (!state || (event.pointerId !== undefined && state.pointerId !== event.pointerId)) {
|
||||
return rejectedTransition(state)
|
||||
}
|
||||
return { state: null, accepted: true, completed: null }
|
||||
case 'reset':
|
||||
return { state: null, accepted: true, completed: null }
|
||||
}
|
||||
}
|
||||
|
||||
export function reduceViewportInteraction(
|
||||
state: ViewportSelectionState | null,
|
||||
event: ViewportInteractionEvent,
|
||||
): ViewportSelectionState | null {
|
||||
return transitionViewportInteraction(state, event).state
|
||||
}
|
||||
167
src/components/interaction/zoomConstraints.ts
Normal file
167
src/components/interaction/zoomConstraints.ts
Normal file
@@ -0,0 +1,167 @@
|
||||
import { scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
|
||||
|
||||
import type { WaveformPoint } from '../../types'
|
||||
import { ZOOM_CONSTRAINTS } from '../core/constants'
|
||||
|
||||
interface PointSeriesSource {
|
||||
points: WaveformPoint[]
|
||||
}
|
||||
|
||||
export type ZoomSeriesGroup = readonly PointSeriesSource[]
|
||||
|
||||
interface ZoomConstraintOptions {
|
||||
minZoomSpan?: number
|
||||
minVisiblePoints?: number
|
||||
}
|
||||
|
||||
const xValueCache = new WeakMap<object, Map<string, number[]>>()
|
||||
|
||||
function normalizedBoundary(boundary: [number, number]): [number, number] {
|
||||
return boundary[0] <= boundary[1] ? [...boundary] : [boundary[1], boundary[0]]
|
||||
}
|
||||
|
||||
function resolveRequiredPointCount(minimum: number | undefined): number {
|
||||
return Number.isFinite(minimum) && (minimum ?? 0) > 0 ? Math.ceil(minimum as number) : 0
|
||||
}
|
||||
|
||||
function uniqueXValues(group: ZoomSeriesGroup, boundary: [number, number]): number[] {
|
||||
const boundaryKey = `${boundary[0]}\u0000${boundary[1]}`
|
||||
const cached = xValueCache.get(group)?.get(boundaryKey)
|
||||
if (cached) return cached
|
||||
const values = new Set<number>()
|
||||
for (const series of group) {
|
||||
for (const point of series.points) {
|
||||
if (point.x >= boundary[0] && point.x <= boundary[1]) values.add(point.x)
|
||||
}
|
||||
}
|
||||
const sorted = Array.from(values).sort((left, right) => left - right)
|
||||
const groupCache = xValueCache.get(group) ?? new Map<string, number[]>()
|
||||
groupCache.set(boundaryKey, sorted)
|
||||
xValueCache.set(group, groupCache)
|
||||
return sorted
|
||||
}
|
||||
|
||||
function minimumPointSpan(
|
||||
groups: readonly ZoomSeriesGroup[],
|
||||
boundary: [number, number],
|
||||
required: number,
|
||||
): number {
|
||||
if (required <= 1) return 0
|
||||
let minimumSpan = 0
|
||||
for (const group of groups) {
|
||||
const values = uniqueXValues(group, boundary)
|
||||
if (values.length < required) return boundary[1] - boundary[0]
|
||||
let groupSpan = Number.POSITIVE_INFINITY
|
||||
for (let index = 0; index + required <= values.length; index += 1) {
|
||||
groupSpan = Math.min(groupSpan, values[index + required - 1] - values[index])
|
||||
}
|
||||
const endpointTolerance =
|
||||
Number.EPSILON * Math.max(1, Math.abs(boundary[0]), Math.abs(boundary[1])) * 16
|
||||
minimumSpan = Math.max(minimumSpan, groupSpan + endpointTolerance)
|
||||
}
|
||||
return minimumSpan
|
||||
}
|
||||
|
||||
export function resolveMinimumZoomSpan(
|
||||
boundary: [number, number],
|
||||
groups: readonly ZoomSeriesGroup[],
|
||||
options: ZoomConstraintOptions,
|
||||
): number {
|
||||
const normalized = normalizedBoundary(boundary)
|
||||
const boundarySpan = normalized[1] - normalized[0]
|
||||
if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return 0
|
||||
const configuredSpan =
|
||||
Number.isFinite(options.minZoomSpan) && (options.minZoomSpan ?? 0) > 0
|
||||
? Math.min(boundarySpan, options.minZoomSpan as number)
|
||||
: 0
|
||||
const required = resolveRequiredPointCount(options.minVisiblePoints)
|
||||
const pointSpan = minimumPointSpan(groups, normalized, required)
|
||||
if (configuredSpan > 0 || required > 0) {
|
||||
return Math.max(configuredSpan, pointSpan)
|
||||
}
|
||||
return boundarySpan / ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE
|
||||
}
|
||||
|
||||
function expandToMinimumPoints(
|
||||
domain: [number, number],
|
||||
boundary: [number, number],
|
||||
groups: readonly ZoomSeriesGroup[],
|
||||
required: number,
|
||||
): [number, number] {
|
||||
if (required <= 0) return domain
|
||||
const endpointTolerance =
|
||||
Number.EPSILON * Math.max(1, Math.abs(boundary[0]), Math.abs(boundary[1])) * 16
|
||||
let expanded = domain
|
||||
for (const group of groups) {
|
||||
const values = uniqueXValues(group, boundary)
|
||||
if (values.length < required) return [...boundary]
|
||||
const visibleCount = values.filter(
|
||||
(value) => value >= expanded[0] && value <= expanded[1],
|
||||
).length
|
||||
if (visibleCount >= required) continue
|
||||
let best: [number, number] | undefined
|
||||
let bestSpan = Number.POSITIVE_INFINITY
|
||||
let bestCenterDistance = Number.POSITIVE_INFINITY
|
||||
const requestedCenter = (expanded[0] + expanded[1]) / 2
|
||||
for (let index = 0; index + required <= values.length; index += 1) {
|
||||
const candidate: [number, number] = [
|
||||
Math.max(boundary[0], Math.min(expanded[0], values[index] - endpointTolerance)),
|
||||
Math.min(
|
||||
boundary[1],
|
||||
Math.max(expanded[1], values[index + required - 1] + endpointTolerance),
|
||||
),
|
||||
]
|
||||
const span = candidate[1] - candidate[0]
|
||||
const centerDistance = Math.abs((candidate[0] + candidate[1]) / 2 - requestedCenter)
|
||||
if (span < bestSpan || (span === bestSpan && centerDistance < bestCenterDistance)) {
|
||||
best = candidate
|
||||
bestSpan = span
|
||||
bestCenterDistance = centerDistance
|
||||
}
|
||||
}
|
||||
expanded = best ?? [...boundary]
|
||||
}
|
||||
return expanded
|
||||
}
|
||||
|
||||
export function constrainZoomDomain(
|
||||
domain: [number, number],
|
||||
boundary: [number, number],
|
||||
groups: readonly ZoomSeriesGroup[],
|
||||
options: ZoomConstraintOptions,
|
||||
): [number, number] {
|
||||
const normalized = normalizedBoundary(boundary)
|
||||
const boundarySpan = normalized[1] - normalized[0]
|
||||
if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return normalized
|
||||
if (Math.abs(domain[1] - domain[0]) >= boundarySpan * (1 - 1e-12)) return normalized
|
||||
const requested: [number, number] = [
|
||||
Math.max(normalized[0], Math.min(domain[0], domain[1])),
|
||||
Math.min(normalized[1], Math.max(domain[0], domain[1])),
|
||||
]
|
||||
const expanded = expandToMinimumPoints(
|
||||
requested,
|
||||
normalized,
|
||||
groups,
|
||||
resolveRequiredPointCount(options.minVisiblePoints),
|
||||
)
|
||||
const minimumSpan = resolveMinimumZoomSpan(normalized, groups, options)
|
||||
const span = Math.max(minimumSpan, Math.min(boundarySpan, expanded[1] - expanded[0]))
|
||||
const center = (expanded[0] + expanded[1]) / 2
|
||||
const start = Math.max(normalized[0], Math.min(center - span / 2, normalized[1] - span))
|
||||
return [start, start + span]
|
||||
}
|
||||
|
||||
export 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)
|
||||
}
|
||||
@@ -70,6 +70,11 @@
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.waveform-track__overlay.is-presentation {
|
||||
cursor: default;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.waveform-track__zero-line {
|
||||
fill: none;
|
||||
pointer-events: none;
|
||||
|
||||
@@ -13,6 +13,7 @@ interface Props {
|
||||
clipPathId: string
|
||||
innerWidth: number
|
||||
zoomable: boolean
|
||||
interactive?: boolean
|
||||
displayMode: WaveformDisplayMode
|
||||
interactionMode?: WaveformInteractionMode
|
||||
frameNumber?: string | number
|
||||
@@ -38,6 +39,7 @@ interface Emits {
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
interactionMode: 'zoom',
|
||||
interactive: true,
|
||||
cleanView: false,
|
||||
zeroLine: () => ({ visible: false, color: '#98a2b3', width: 1, dash: '6 4' }),
|
||||
})
|
||||
@@ -92,7 +94,7 @@ const resolvedFrameStyle = computed(() => {
|
||||
/>
|
||||
|
||||
<rect
|
||||
v-if="!track.isEmpty && !cleanView"
|
||||
v-if="!track.isEmpty"
|
||||
class="waveform-track__plot-frame waveform-chart__plot-frame"
|
||||
:width="track.width ?? innerWidth"
|
||||
:height="track.height"
|
||||
@@ -116,9 +118,11 @@ const resolvedFrameStyle = computed(() => {
|
||||
v-if="!track.isEmpty && track.hasVisibleSeries && displayMode === 'independent'"
|
||||
class="waveform-track__overlay waveform-track__overlay--independent waveform-chart__overlay waveform-chart__overlay--independent"
|
||||
:class="{
|
||||
'is-zoomable': zoomable && interactionMode === 'zoom',
|
||||
'is-annotating': interactionMode === 'annotation',
|
||||
'is-zoomable': interactive && zoomable && interactionMode === 'zoom',
|
||||
'is-annotating': interactive && interactionMode === 'annotation',
|
||||
'is-presentation': !interactive,
|
||||
}"
|
||||
:data-interactive="interactive"
|
||||
:data-independent-overlay-index="track.index"
|
||||
:width="track.width ?? innerWidth"
|
||||
:height="track.height"
|
||||
|
||||
@@ -3,7 +3,7 @@ import { axisBottom, axisLeft, axisRight, select } from 'd3'
|
||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||
|
||||
import type { WaveformAxesOptions } from '../../types'
|
||||
import { formatAxisTime, formatScientificAxisLabel } from '../../utils'
|
||||
import { formatScientificAxisLabel, formatXAxisLabel } from '../../utils'
|
||||
import type { DisplaySeries, TrackLayout, WaveformYAxisLayout } from '../core/types'
|
||||
|
||||
interface Props {
|
||||
@@ -43,8 +43,11 @@ function renderAxes() {
|
||||
const element = yAxisElements.value[index]
|
||||
if (!element) return
|
||||
const [axisMin, axisMax] = axis.scale.domain()
|
||||
const topTickValue = Math.max(...axis.tickValues)
|
||||
const yAxis = (axis.side === 'left' ? axisLeft(axis.scale) : axisRight(axis.scale))
|
||||
.tickFormat((value) => formatScientificAxisLabel(Number(value), { axisMin, axisMax }))
|
||||
.tickFormat((value) =>
|
||||
formatScientificAxisLabel(Number(value), { axisMin, axisMax, topTickValue }),
|
||||
)
|
||||
.tickSize(-4)
|
||||
.tickPadding(7)
|
||||
.tickSizeOuter(0)
|
||||
@@ -63,10 +66,12 @@ function renderAxes() {
|
||||
axisBottom(props.track.xScale)
|
||||
.tickValues(props.track.xAxisTickValues)
|
||||
.tickFormat((value) =>
|
||||
formatAxisTime(
|
||||
formatXAxisLabel(
|
||||
Number(value),
|
||||
props.timeUnit,
|
||||
props.track.xScale.domain() as [number, number],
|
||||
props.timeUnit,
|
||||
'tick',
|
||||
props.axes?.x?.labelFormatter,
|
||||
),
|
||||
)
|
||||
.tickSize(-4)
|
||||
@@ -90,6 +95,7 @@ watch(
|
||||
() => props.track.xAxisTickValues,
|
||||
() => props.track.yAxisTickValues,
|
||||
() => props.timeUnit,
|
||||
() => props.axes?.x?.labelFormatter,
|
||||
() => props.axes?.x?.lineVisible,
|
||||
() => props.axes?.y?.lineVisible,
|
||||
],
|
||||
@@ -103,14 +109,14 @@ watch(
|
||||
|
||||
<template>
|
||||
<g
|
||||
v-if="track.showXAxis && !cleanView"
|
||||
v-if="track.showXAxis"
|
||||
ref="xAxisElement"
|
||||
class="waveform-track__axis waveform-track__axis--x waveform-chart__axis waveform-chart__axis--x"
|
||||
:class="{ 'waveform-track__axis--line-hidden': axes?.x?.lineVisible === false }"
|
||||
:transform="`translate(0, ${track.height})`"
|
||||
/>
|
||||
<g
|
||||
v-if="track.showXAxis && !cleanView"
|
||||
v-if="track.showXAxis"
|
||||
class="waveform-track__axis-endpoints waveform-chart__axis-endpoints"
|
||||
:transform="`translate(0, ${track.height})`"
|
||||
font-family="sans-serif"
|
||||
@@ -136,19 +142,8 @@ watch(
|
||||
{{ track.endpointLabels.end }}
|
||||
</text>
|
||||
</g>
|
||||
<text
|
||||
v-if="track.showXAxis && track.xAxisExponent && !cleanView"
|
||||
class="waveform-track__axis-exponent waveform-track__axis-exponent--x waveform-chart__axis-exponent waveform-chart__axis-exponent--x"
|
||||
:x="track.width ?? innerWidth"
|
||||
:y="track.height + 27"
|
||||
text-anchor="end"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ track.xAxisExponent }}
|
||||
</text>
|
||||
|
||||
<g
|
||||
v-for="axis in track.isEmpty || cleanView ? [] : track.yAxes"
|
||||
v-for="axis in track.isEmpty ? [] : track.yAxes"
|
||||
:key="`y-axis-${track.index}-${axis.index}`"
|
||||
:ref="(element) => setYAxisElement(element, axis.index)"
|
||||
class="waveform-track__axis waveform-track__axis--y waveform-chart__axis waveform-chart__axis--y"
|
||||
@@ -160,22 +155,6 @@ watch(
|
||||
:data-y-axis-side="axis.side"
|
||||
:transform="`translate(${axis.x}, 0)`"
|
||||
/>
|
||||
<text
|
||||
v-for="axis in track.isEmpty || cleanView
|
||||
? []
|
||||
: track.yAxes.filter((item) => item.exponentLabel)"
|
||||
:key="`y-axis-exponent-${track.index}-${axis.index}`"
|
||||
class="waveform-track__axis-exponent waveform-track__axis-exponent--y waveform-chart__axis-exponent waveform-chart__axis-exponent--y"
|
||||
:data-y-axis-index="axis.index"
|
||||
:x="axis.exponentX"
|
||||
y="0"
|
||||
dy="0.32em"
|
||||
:text-anchor="axis.side === 'left' ? 'end' : 'start'"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{{ axis.exponentLabel }}
|
||||
</text>
|
||||
|
||||
<g
|
||||
v-if="
|
||||
!cleanView &&
|
||||
|
||||
@@ -12,6 +12,7 @@ export type {
|
||||
WaveformAnnotationStyle,
|
||||
WaveformAnnotation,
|
||||
WaveformRenderingOptions,
|
||||
WaveformPlotMargin,
|
||||
WaveformTitleTextStyle,
|
||||
WaveformTitleOptions,
|
||||
WaveformFrameStyle,
|
||||
|
||||
@@ -72,7 +72,7 @@ describe('WaveformChart', () => {
|
||||
expect(endTicks[3]).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps one separate shared exponent for every compact Y axis', async () => {
|
||||
it('prefixes one shared exponent to the largest visible tick on every compact Y axis', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'series',
|
||||
@@ -83,7 +83,7 @@ describe('WaveformChart', () => {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 254 },
|
||||
{ x: 1, y: 2540 },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -103,14 +103,14 @@ describe('WaveformChart', () => {
|
||||
)
|
||||
|
||||
const axes = wrapper.findAll('.waveform-chart__axis--y')
|
||||
const exponents = wrapper.findAll('.waveform-chart__axis-exponent--y')
|
||||
expect(axes).toHaveLength(2)
|
||||
expect(exponents.map((label) => label.text())).toEqual(['E+02', 'E-04'])
|
||||
axes.forEach((axis) => {
|
||||
axes.forEach((axis, index) => {
|
||||
const labels = axis.findAll('.tick text').map((tick) => tick.text())
|
||||
expect(labels.every((label) => !label.startsWith('E'))).toBe(true)
|
||||
expect(labels.every((label) => /^-?\d+\.\d{2}$/.test(label))).toBe(true)
|
||||
const exponentLabels = labels.filter((label) => label.startsWith('E'))
|
||||
expect(exponentLabels).toHaveLength(1)
|
||||
expect(exponentLabels[0]).toMatch(index === 0 ? /^E\+03 / : /^E-04 /)
|
||||
})
|
||||
expect(wrapper.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('prefers a trimmed series name and falls back to yLabel for unnamed data', async () => {
|
||||
|
||||
@@ -178,7 +178,7 @@ describe('WaveformChart', () => {
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1, y: 5 }])
|
||||
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
|
||||
expect(wrapper.get('.waveform-chart__tooltip').text()).toContain('ms: 1,000.0000')
|
||||
expect(wrapper.get('.waveform-chart__tooltip-time').text()).toBe('ms: 1,000')
|
||||
const crosshairLines = wrapper.findAll('.waveform-chart__crosshair line')
|
||||
expect(crosshairLines).toHaveLength(1)
|
||||
expect(crosshairLines[0].attributes('x1')).toBe(crosshairLines[0].attributes('x2'))
|
||||
@@ -303,7 +303,7 @@ describe('WaveformChart', () => {
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('.waveform-chart__tooltip').text()).toContain('ms: 1,000.0000')
|
||||
expect(wrapper.get('.waveform-chart__tooltip-time').text()).toBe('ms: 1,000')
|
||||
expect(wrapper.findAll('.waveform-chart__crosshair line')).toHaveLength(1)
|
||||
expect(wrapper.get('.waveform-chart__line').element).toBe(pathBeforeHover)
|
||||
expect(chartUpdate).not.toHaveBeenCalled()
|
||||
|
||||
@@ -62,15 +62,15 @@ describe('WaveformChart', () => {
|
||||
],
|
||||
}
|
||||
const wrapper = await mountSizedChart(firstData)
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
|
||||
|
||||
firstData.points.push({ x: 2, y: 2 })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
|
||||
|
||||
await wrapper.setProps({ data: { ...firstData, points: [...firstData.points] } })
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
|
||||
})
|
||||
|
||||
it('keeps controlled annotations when replacing the loaded data window', async () => {
|
||||
@@ -170,7 +170,7 @@ describe('WaveformChart', () => {
|
||||
expect(wrapper.findAll('.waveform-chart__track-label')).toHaveLength(0)
|
||||
expect(
|
||||
wrapper.findAll('.waveform-chart__axis-endpoint--end').map((item) => item.text()),
|
||||
).toEqual(['1.00', '2.00'])
|
||||
).toEqual(['1000', '2000'])
|
||||
})
|
||||
|
||||
it('keeps the zero Y-axis label on upper compact tracks', async () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { flushAnimationFrames } from '../../test/setup'
|
||||
import type { WaveformXAxisLabelFormatter } from '../../types'
|
||||
import { type WaveformData } from '../waveform'
|
||||
|
||||
import { gridSeries, mountSizedChart } from '../../test/waveformChart'
|
||||
@@ -162,35 +163,42 @@ describe('WaveformChart', () => {
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').attributes('x')).toBe(
|
||||
String(trackWidth),
|
||||
)
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4.99')
|
||||
expect(wrapper.get('.waveform-chart__axis-exponent--x').text()).toBe('E+03')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4990.3')
|
||||
expect(wrapper.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('uses one shared scientific exponent only for large and tiny Y-axis domains', async () => {
|
||||
const cases = [
|
||||
{ values: [0, 50], exponent: null },
|
||||
{ values: [0, 254], exponent: 'E+02' },
|
||||
{ values: [0, 0.0002], exponent: 'E-04' },
|
||||
{ values: [0, 50], yDomain: [0, 50] as [number, number], exponent: null },
|
||||
{ values: [1000, 3000], yDomain: [1000, 3000] as [number, number], exponent: 'E+03' },
|
||||
{
|
||||
values: [0.0001, 0.0003],
|
||||
yDomain: [0.0001, 0.0003] as [number, number],
|
||||
exponent: 'E-04',
|
||||
},
|
||||
]
|
||||
|
||||
for (const { values, exponent } of cases) {
|
||||
const wrapper = await mountSizedChart({
|
||||
kind: 'points',
|
||||
for (const { values, yDomain, exponent } of cases) {
|
||||
const data = {
|
||||
kind: 'points' as const,
|
||||
points: values.map((y, x) => ({ x, y })),
|
||||
})
|
||||
}
|
||||
const originalValues = data.points.map((point) => point.y)
|
||||
const wrapper = await mountSizedChart(data, { yDomain })
|
||||
const labels = wrapper
|
||||
.get('.waveform-chart__axis--y')
|
||||
.findAll('.tick text')
|
||||
.map((tick) => tick.text())
|
||||
const exponentLabel = wrapper.find('.waveform-chart__axis-exponent--y')
|
||||
const exponentLabels = labels.filter((label) => label.startsWith('E'))
|
||||
|
||||
if (exponent === null) {
|
||||
expect(exponentLabel.exists()).toBe(false)
|
||||
expect(exponentLabels).toHaveLength(0)
|
||||
} else {
|
||||
expect(exponentLabel.text()).toBe(exponent)
|
||||
expect(labels.every((label) => !label.startsWith('E'))).toBe(true)
|
||||
expect(labels.every((label) => /^-?\d+\.\d{2}$/.test(label))).toBe(true)
|
||||
expect(exponentLabels).toHaveLength(1)
|
||||
expect(exponentLabels[0]).toMatch(new RegExp(`^${exponent.replace('+', '\\+')} `))
|
||||
}
|
||||
expect(wrapper.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
|
||||
expect(data.points.map((point) => point.y)).toEqual(originalValues)
|
||||
|
||||
wrapper.unmount()
|
||||
}
|
||||
@@ -212,13 +220,13 @@ describe('WaveformChart', () => {
|
||||
|
||||
expect(millisecondsChart.text()).toContain('时间(ms)')
|
||||
expect(millisecondTicks.length).toBeGreaterThan(0)
|
||||
expect(millisecondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
|
||||
expect(millisecondsChart.get('.waveform-chart__axis-exponent--x').text()).toBe('E+03')
|
||||
expect(millisecondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
|
||||
expect(millisecondsChart.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
|
||||
expect(millisecondsChart.find('.waveform-chart__watermark').exists()).toBe(false)
|
||||
|
||||
const secondsChart = await mountSizedChart(data, { timeUnit: 's', xLabel: 'Elapsed time' })
|
||||
expect(secondsChart.text()).toContain('Elapsed time')
|
||||
expect(secondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
|
||||
expect(secondsChart.get('.waveform-chart__axis-endpoint--end').text()).toBe('1')
|
||||
expect(secondsChart.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
|
||||
})
|
||||
|
||||
@@ -237,15 +245,15 @@ describe('WaveformChart', () => {
|
||||
|
||||
expect(start.attributes('x')).toBe('0')
|
||||
expect(start.attributes('text-anchor')).toBe('start')
|
||||
expect(start.text()).toBe('0.00')
|
||||
expect(start.text()).toBe('0')
|
||||
expect(end.attributes('x')).toBe(
|
||||
wrapper.get('.waveform-chart__track').attributes('data-track-width'),
|
||||
)
|
||||
expect(end.attributes('text-anchor')).toBe('end')
|
||||
expect(end.text()).toBe('2.00')
|
||||
expect(end.text()).toBe('1999')
|
||||
expect(middleTickLabels.length).toBeGreaterThan(0)
|
||||
expect(middleTickLabels).not.toContain('0.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-exponent--x').text()).toBe('E+03')
|
||||
expect(middleTickLabels).not.toContain('0')
|
||||
expect(wrapper.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.waveform-chart__grid--major line').length).toBeGreaterThan(
|
||||
middleTickLabels.length,
|
||||
)
|
||||
@@ -260,6 +268,70 @@ describe('WaveformChart', () => {
|
||||
expect(endpointGroup.attributes('font-size')).toBe(xAxis.attributes('font-size'))
|
||||
})
|
||||
|
||||
it('formats X-axis ticks and endpoints with display-unit values and source context', async () => {
|
||||
const data: WaveformData = {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0.125, y: 0 },
|
||||
{ x: 1.875, y: 1 },
|
||||
],
|
||||
}
|
||||
const originalPoints = data.points.map((point) => ({ ...point }))
|
||||
const labelFormatter: WaveformXAxisLabelFormatter = (value, context) =>
|
||||
`${context.kind}:${value / 10}`
|
||||
const formatter = vi.fn(labelFormatter)
|
||||
const wrapper = await mountSizedChart(data, {
|
||||
timeUnit: 'ms',
|
||||
axes: { x: { labelFormatter: formatter } },
|
||||
})
|
||||
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('start:12.5')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('end:187.5')
|
||||
expect(
|
||||
wrapper
|
||||
.get('.waveform-chart__axis--x')
|
||||
.findAll('.tick text')
|
||||
.every((tick) => tick.text().startsWith('tick:')),
|
||||
).toBe(true)
|
||||
|
||||
const startCall = formatter.mock.calls.find(([, context]) => context.kind === 'start')
|
||||
const tickCall = formatter.mock.calls.find(([, context]) => context.kind === 'tick')
|
||||
const endCall = formatter.mock.calls.find(([, context]) => context.kind === 'end')
|
||||
expect(startCall).toEqual([
|
||||
125,
|
||||
{
|
||||
kind: 'start',
|
||||
rawValue: 0.125,
|
||||
timeUnit: 'ms',
|
||||
domain: [0.125, 1.875],
|
||||
displayDomain: [125, 1875],
|
||||
},
|
||||
])
|
||||
expect(tickCall?.[0]).toBeTypeOf('number')
|
||||
expect(tickCall?.[1]).toMatchObject({
|
||||
kind: 'tick',
|
||||
timeUnit: 'ms',
|
||||
domain: [0.125, 1.875],
|
||||
displayDomain: [125, 1875],
|
||||
})
|
||||
expect(endCall?.[1]).toMatchObject({ kind: 'end', rawValue: 1.875 })
|
||||
expect(data.points).toEqual(originalPoints)
|
||||
|
||||
await wrapper.setProps({
|
||||
axes: { x: { labelFormatter: (value: number) => `updated:${value}` } },
|
||||
})
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('updated:125')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('updated:1875')
|
||||
expect(
|
||||
wrapper
|
||||
.get('.waveform-chart__axis--x')
|
||||
.findAll('.tick text')
|
||||
.every((tick) => tick.text().startsWith('updated:')),
|
||||
).toBe(true)
|
||||
expect(data.points).toEqual(originalPoints)
|
||||
})
|
||||
|
||||
it('keeps zoom-change domains in source seconds', async () => {
|
||||
const wrapper = await mountSizedChart({
|
||||
kind: 'points',
|
||||
@@ -300,6 +372,8 @@ describe('WaveformChart', () => {
|
||||
)
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe(initialStart)
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).not.toBe(initialEnd)
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toContain('.')
|
||||
expect(
|
||||
Number.isFinite(Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())),
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -59,7 +59,7 @@ describe('WaveformChart', () => {
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.waveform-chart__axis--x')).toHaveLength(1)
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
|
||||
})
|
||||
|
||||
it('updates rendering props and disables zoom interaction', async () => {
|
||||
|
||||
@@ -33,10 +33,10 @@ describe('WaveformChart', () => {
|
||||
tracks[0].get('.waveform-chart__y-axis-label-bg').attributes('x'),
|
||||
)
|
||||
|
||||
expect(labelX).toBe(-103)
|
||||
expect(labelX).toBe(-74)
|
||||
expect(labelBackgroundX).toBe(labelX - 12)
|
||||
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(119)
|
||||
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(119)
|
||||
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(90)
|
||||
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(90)
|
||||
})
|
||||
|
||||
it('keeps a tick-only gutter when channel labels are empty', async () => {
|
||||
@@ -62,8 +62,8 @@ describe('WaveformChart', () => {
|
||||
const secondLeft = Number(tracks[1].attributes('data-track-left'))
|
||||
|
||||
expect(wrapper.findAll('.waveform-chart__y-axis-label')).toHaveLength(0)
|
||||
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(89)
|
||||
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(89)
|
||||
expect(Number(wrapper.attributes('data-chart-left-margin'))).toBe(64)
|
||||
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(60)
|
||||
})
|
||||
|
||||
it('keeps the Y-axis label gutter stable while paging between value ranges', async () => {
|
||||
@@ -122,7 +122,7 @@ describe('WaveformChart', () => {
|
||||
const tracks = wrapper.findAll('.waveform-chart__track')
|
||||
const firstWidth = Number(tracks[0].attributes('data-track-width'))
|
||||
const secondLeft = Number(tracks[1].attributes('data-track-left'))
|
||||
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(39)
|
||||
expect(secondLeft - firstWidth).toBeGreaterThanOrEqual(32)
|
||||
})
|
||||
|
||||
it('uses one shared overlay and bottom-row x axes for separated and compact grids', async () => {
|
||||
@@ -225,7 +225,7 @@ describe('WaveformChart', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('uses each track x domain in independent mode instead of the shared initial domain', async () => {
|
||||
it('uses the shared initial x domain for every independent track by default', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'series',
|
||||
@@ -262,8 +262,10 @@ describe('WaveformChart', () => {
|
||||
)
|
||||
|
||||
const tracks = wrapper.findAll('.waveform-chart__track')
|
||||
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('6.00')
|
||||
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5.00')
|
||||
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8000')
|
||||
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000')
|
||||
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-8000')
|
||||
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000')
|
||||
})
|
||||
|
||||
it('uses an explicit initial x domain override for an independent track', async () => {
|
||||
@@ -291,7 +293,32 @@ describe('WaveformChart', () => {
|
||||
},
|
||||
)
|
||||
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
|
||||
})
|
||||
|
||||
it('reacts to exact fixed Y-domain props and returns to automatic bounds', async () => {
|
||||
const wrapper = await mountSizedChart({
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 100 },
|
||||
],
|
||||
})
|
||||
const yTickLabels = () =>
|
||||
wrapper
|
||||
.get('.waveform-chart__axis--y')
|
||||
.findAll('.tick text')
|
||||
.map((tick) => tick.text())
|
||||
|
||||
await wrapper.setProps({ yDomain: [3, 97] })
|
||||
await flushPromises()
|
||||
expect(yTickLabels()).toContain('3')
|
||||
expect(yTickLabels()).toContain('97')
|
||||
|
||||
await wrapper.setProps({ yDomain: undefined })
|
||||
await flushPromises()
|
||||
expect(yTickLabels()).not.toContain('3')
|
||||
expect(yTickLabels()).not.toContain('97')
|
||||
})
|
||||
|
||||
it('keeps annotations bound to their channel while paging', async () => {
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('WaveformChart', () => {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 254 },
|
||||
{ x: 1, y: 2540 },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -51,13 +51,13 @@ describe('WaveformChart', () => {
|
||||
)
|
||||
|
||||
expect(
|
||||
wrapper
|
||||
.findAll('.waveform-chart__axis-exponent--y')
|
||||
.map((label) => [label.attributes('data-y-axis-index'), label.text()]),
|
||||
).toEqual([
|
||||
['1', 'E+02'],
|
||||
['2', 'E-04'],
|
||||
])
|
||||
wrapper.findAll('.waveform-chart__axis--y').map((axis) =>
|
||||
axis
|
||||
.findAll('.tick text')
|
||||
.map((label) => label.text())
|
||||
.filter((label) => label.startsWith('E')),
|
||||
),
|
||||
).toEqual([[], [expect.stringMatching(/^E\+03 /)], [expect.stringMatching(/^E-04 /)]])
|
||||
})
|
||||
|
||||
it('reprojects annotations with the Y axis assigned to their series', async () => {
|
||||
|
||||
@@ -54,8 +54,9 @@ describe('WaveformChart', () => {
|
||||
],
|
||||
})
|
||||
|
||||
expect(first.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
|
||||
expect(second.get('.waveform-chart__axis-exponent--y').text()).toBe('E+04')
|
||||
expect(first.get('.waveform-chart__axis--y').text()).not.toContain('E')
|
||||
expect(second.get('.waveform-chart__axis--y').text()).toContain('E+04 ')
|
||||
expect(second.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders a configurable zero line only when the Y domain contains zero', async () => {
|
||||
@@ -146,7 +147,7 @@ describe('WaveformChart', () => {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: -1 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 10_000, y: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -227,8 +228,8 @@ describe('WaveformChart', () => {
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 1 },
|
||||
{ x: 1, y: 2 },
|
||||
{ x: 0, y: 10_000 },
|
||||
{ x: 10_000, y: 20_000 },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -239,7 +240,7 @@ describe('WaveformChart', () => {
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 2 },
|
||||
{ x: 1, y: 3 },
|
||||
{ x: 10_000, y: 3 },
|
||||
],
|
||||
},
|
||||
},
|
||||
@@ -252,6 +253,8 @@ describe('WaveformChart', () => {
|
||||
annotations: [{ id: 'note', seriesId: 'first', x: 0.5, y: 0, text: 'hidden note' }],
|
||||
zeroLine: { visible: true },
|
||||
title: { text: 'hidden title' },
|
||||
axes: { x: { lineVisible: false }, y: { lineVisible: false } },
|
||||
frameStyle: { borderColor: '#dc2626', borderWidth: 2, borderStyle: 'dashed' as const },
|
||||
}
|
||||
const regularWrapper = await mountSizedChart(data, sharedProps)
|
||||
const wrapper = await mountSizedChart(data, {
|
||||
@@ -285,13 +288,32 @@ describe('WaveformChart', () => {
|
||||
expect(wrapper.find('.waveform-chart__overlay--independent').exists()).toBe(true)
|
||||
expect(wrapper.get('.waveform-chart__title-area').attributes('aria-hidden')).toBe('true')
|
||||
expect(wrapper.find('.waveform-chart__title-visual').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-chart__axis').exists()).toBe(false)
|
||||
const xAxis = wrapper.get('.waveform-chart__axis--x')
|
||||
const yAxes = wrapper.findAll('.waveform-chart__axis--y')
|
||||
expect(yAxes).toHaveLength(2)
|
||||
expect(xAxis.findAll('.tick line').length).toBeGreaterThan(0)
|
||||
expect(xAxis.findAll('.tick text').length).toBeGreaterThan(0)
|
||||
yAxes.forEach((axis) => {
|
||||
expect(axis.findAll('.tick line').length).toBeGreaterThan(0)
|
||||
expect(axis.findAll('.tick text').length).toBeGreaterThan(0)
|
||||
expect(axis.get('path.domain').attributes('display')).toBe('none')
|
||||
})
|
||||
expect(xAxis.get('path.domain').attributes('display')).toBe('none')
|
||||
expect(wrapper.findAll('.waveform-chart__axis-endpoint')).toHaveLength(2)
|
||||
expect(wrapper.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
|
||||
expect(yAxes.some((axis) => axis.text().includes('E+04 '))).toBe(true)
|
||||
expect(wrapper.find('.waveform-chart__axis-exponent--y').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-chart__grid').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-chart__plot-frame').exists()).toBe(false)
|
||||
expect(wrapper.get('.waveform-chart__plot-frame').attributes()).toMatchObject({
|
||||
stroke: '#dc2626',
|
||||
'stroke-width': '2',
|
||||
'stroke-dasharray': '6 4',
|
||||
})
|
||||
expect(wrapper.find('.waveform-chart__plot-background').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-chart__watermark').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-chart__legend-layer').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-chart__label').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-chart__y-axis-label').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-chart__zero-line').exists()).toBe(false)
|
||||
expect(wrapper.find('.waveform-annotation-layer').exists()).toBe(false)
|
||||
expect(wrapper.find('.ant-pagination').exists()).toBe(false)
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -105,6 +105,65 @@ describe('WaveformChart', () => {
|
||||
expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('300')
|
||||
})
|
||||
|
||||
it('configures plot top and bottom margins and reacts to updates', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
|
||||
{
|
||||
displayMode: 'compact',
|
||||
grid: { rowCount: 1, columnCount: 1 },
|
||||
plotMargin: { top: 30, bottom: 70 },
|
||||
},
|
||||
)
|
||||
|
||||
expect(wrapper.attributes('data-plot-margin-top')).toBe('30')
|
||||
expect(wrapper.attributes('data-plot-margin-bottom')).toBe('70')
|
||||
expect(wrapper.get('.waveform-chart__overlay').attributes('height')).toBe('260')
|
||||
expect(wrapper.get('.waveform-chart__svg > g').attributes('transform')).toContain(', 30)')
|
||||
|
||||
await wrapper.setProps({ plotMargin: { top: 12 } })
|
||||
|
||||
expect(wrapper.attributes('data-plot-margin-top')).toBe('12')
|
||||
expect(wrapper.attributes('data-plot-margin-bottom')).toBe('52')
|
||||
expect(wrapper.get('.waveform-chart__overlay').attributes('height')).toBe('296')
|
||||
})
|
||||
|
||||
it('falls back to default plot margins for invalid values', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
|
||||
{
|
||||
displayMode: 'compact',
|
||||
grid: { rowCount: 1, columnCount: 1 },
|
||||
plotMargin: { top: -1, bottom: Number.NaN },
|
||||
},
|
||||
)
|
||||
|
||||
expect(wrapper.attributes('data-plot-margin-top')).toBe('18')
|
||||
expect(wrapper.attributes('data-plot-margin-bottom')).toBe('52')
|
||||
expect(wrapper.get('.waveform-chart__overlay').attributes('height')).toBe('290')
|
||||
})
|
||||
|
||||
it('keeps the chart title and X-axis title fixed when plot margins change', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
|
||||
{
|
||||
displayMode: 'compact',
|
||||
grid: { rowCount: 1, columnCount: 1 },
|
||||
title: { text: '固定标题' },
|
||||
},
|
||||
)
|
||||
const titleAreaStyle = wrapper.get('.waveform-chart__title-area').attributes('style')
|
||||
const xAxisTitle = wrapper.get('.waveform-chart__x-label')
|
||||
const initialX = xAxisTitle.attributes('x')
|
||||
const initialY = xAxisTitle.attributes('y')
|
||||
|
||||
await wrapper.setProps({ plotMargin: { top: 60, bottom: 90 } })
|
||||
|
||||
expect(wrapper.get('.waveform-chart__title-area').attributes('style')).toBe(titleAreaStyle)
|
||||
expect(wrapper.get('.waveform-chart__x-label').attributes('x')).toBe(initialX)
|
||||
expect(wrapper.get('.waveform-chart__x-label').attributes('y')).toBe(initialY)
|
||||
expect(wrapper.get('.waveform-chart__svg > g').attributes('transform')).toContain(', 60)')
|
||||
})
|
||||
|
||||
it('does not render or reserve space for missing, hidden, or blank titles', async () => {
|
||||
for (const title of [undefined, { visible: false, text: '隐藏标题' }, { text: ' ' }]) {
|
||||
const wrapper = await mountSizedChart(
|
||||
|
||||
@@ -38,6 +38,21 @@ describe('WaveformChart', () => {
|
||||
expect(domain[1]).toBeLessThanOrEqual(1)
|
||||
expect(endpoints()[0]).not.toBe(initialEndpoints[0])
|
||||
expect(endpoints()[1]).toBe(initialEndpoints[1])
|
||||
|
||||
overlays[0].element.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaY: 4_000,
|
||||
clientX: firstWidth - 1,
|
||||
clientY: 130,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
|
||||
expect(endpoints()[0]).toBe(initialEndpoints[0])
|
||||
expect(endpoints()[1]).toBe(initialEndpoints[1])
|
||||
})
|
||||
|
||||
it('emits one zoom-end payload after a shared zoom gesture completes', async () => {
|
||||
@@ -224,6 +239,56 @@ describe('WaveformChart', () => {
|
||||
window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' }))
|
||||
})
|
||||
|
||||
it('keeps a fixed Y domain through panning and viewport reset', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 100 },
|
||||
],
|
||||
},
|
||||
{ pannable: true, yDomain: [3, 97] },
|
||||
)
|
||||
const overlay = wrapper.get('.waveform-chart__overlay--independent')
|
||||
const width = Number(overlay.attributes('width'))
|
||||
const height = Number(overlay.attributes('height'))
|
||||
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, top: 0, width, height }),
|
||||
})
|
||||
const yTickLabels = () =>
|
||||
wrapper
|
||||
.get('.waveform-chart__axis--y')
|
||||
.findAll('.tick text')
|
||||
.map((tick) => tick.text())
|
||||
const initialLabels = yTickLabels()
|
||||
|
||||
await wrapper.trigger('pointerenter')
|
||||
window.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', cancelable: true }))
|
||||
const down = new MouseEvent('pointerdown', {
|
||||
button: 0,
|
||||
clientX: width / 2,
|
||||
clientY: height / 2,
|
||||
bubbles: true,
|
||||
})
|
||||
Object.defineProperty(down, 'pointerId', { value: 34 })
|
||||
overlay.element.dispatchEvent(down)
|
||||
const move = new MouseEvent('pointermove', {
|
||||
clientX: width / 2 + 20,
|
||||
clientY: height / 2 + 40,
|
||||
bubbles: true,
|
||||
})
|
||||
Object.defineProperty(move, 'pointerId', { value: 34 })
|
||||
overlay.element.dispatchEvent(move)
|
||||
await flushPromises()
|
||||
|
||||
expect(yTickLabels()).toEqual(initialLabels)
|
||||
;(wrapper.vm as unknown as { resetViewport: () => void }).resetViewport()
|
||||
await flushPromises()
|
||||
expect(yTickLabels()).toEqual(initialLabels)
|
||||
window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' }))
|
||||
})
|
||||
|
||||
it('does not activate pannable on a chart that the pointer is outside', async () => {
|
||||
const data: WaveformData = {
|
||||
kind: 'points',
|
||||
|
||||
@@ -79,8 +79,8 @@ describe('WaveformChart', () => {
|
||||
|
||||
expect(wrapper.emitted('zoom-change')).toBeUndefined()
|
||||
expect(wrapper.emitted('zoom-end')).toBeUndefined()
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
|
||||
})
|
||||
|
||||
it('ignores independent viewport dragging', async () => {
|
||||
@@ -207,7 +207,7 @@ describe('WaveformChart', () => {
|
||||
expect(secondDomain[1] - secondDomain[0]).toBeCloseTo(10)
|
||||
})
|
||||
|
||||
it('keeps one global domain while loading narrower and wider windows', async () => {
|
||||
it('zooms a shared viewport back out after loading a narrower data window', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'points',
|
||||
@@ -256,9 +256,57 @@ describe('WaveformChart', () => {
|
||||
|
||||
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
|
||||
await dispatchWheel(4000)
|
||||
const currentDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||
const zoomedOutDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount + 1)
|
||||
expect(zoomedOutDomain[1] - zoomedOutDomain[0]).toBeCloseTo(100)
|
||||
|
||||
const fullDomainEventCount = wrapper.emitted('zoom-change')?.length ?? 0
|
||||
await dispatchWheel(4000)
|
||||
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(fullDomainEventCount)
|
||||
})
|
||||
|
||||
it('allows zooming out when the minimum visible point count blocks zooming in', async () => {
|
||||
const wrapper = await mountSizedChart({
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 10 },
|
||||
{ x: 2, y: 20 },
|
||||
],
|
||||
})
|
||||
const overlay = wrapper.get('.waveform-chart__overlay--independent')
|
||||
const width = Number(overlay.attributes('width'))
|
||||
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, top: 0, width, height: 290 }),
|
||||
})
|
||||
const dispatchWheel = async (deltaY: number) => {
|
||||
overlay.element.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaY,
|
||||
clientX: width / 2,
|
||||
clientY: 145,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
await dispatchWheel(-4000)
|
||||
const zoomedDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||
expect(zoomedDomain[1] - zoomedDomain[0]).toBeCloseTo(0.05)
|
||||
|
||||
await wrapper.setProps({ minVisiblePoints: 10 })
|
||||
await flushPromises()
|
||||
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
|
||||
await dispatchWheel(-1000)
|
||||
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount)
|
||||
expect(currentDomain[1] - currentDomain[0]).toBeCloseTo(2.5)
|
||||
|
||||
await dispatchWheel(4000)
|
||||
const zoomedOutDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount + 1)
|
||||
expect(zoomedOutDomain).toEqual([0, 2])
|
||||
})
|
||||
|
||||
it('resets a shared viewport on double-click and emits zoom-reset', async () => {
|
||||
@@ -285,14 +333,14 @@ describe('WaveformChart', () => {
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe('0.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe('0')
|
||||
|
||||
overlay.element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('zoom-reset')).toHaveLength(1)
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
|
||||
})
|
||||
|
||||
it('exposes resetViewport for independent tracks', async () => {
|
||||
@@ -316,13 +364,13 @@ describe('WaveformChart', () => {
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).not.toBe('0.00')
|
||||
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).not.toBe('0')
|
||||
|
||||
const chart = wrapper.vm as unknown as { resetViewport: () => void }
|
||||
chart.resetViewport()
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0.00')
|
||||
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1.00')
|
||||
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0')
|
||||
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1000')
|
||||
})
|
||||
})
|
||||
|
||||
132
src/components/waveformChartCases/viewportLifecycle.test.ts
Normal file
132
src/components/waveformChartCases/viewportLifecycle.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { gridSeries, mountSizedChart } from '../../test/waveformChart'
|
||||
import WaveformChartView from '../WaveformChartView.vue'
|
||||
|
||||
describe('WaveformChart viewport lifecycle', () => {
|
||||
it('cleans an active shared gesture when its track is removed', async () => {
|
||||
const wrapper = await mountSizedChart(gridSeries(2), {
|
||||
displayMode: 'separated',
|
||||
grid: { rowCount: 2, columnCount: 1 },
|
||||
})
|
||||
const overlay = wrapper.get('.waveform-chart__overlay--shared')
|
||||
const width = Number(overlay.attributes('width'))
|
||||
const height = Number(overlay.attributes('height'))
|
||||
const releasePointerCapture = vi.fn()
|
||||
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, top: 0, width, height }),
|
||||
})
|
||||
Object.defineProperty(overlay.element, 'setPointerCapture', { value: vi.fn() })
|
||||
Object.defineProperty(overlay.element, 'releasePointerCapture', {
|
||||
value: releasePointerCapture,
|
||||
})
|
||||
|
||||
const dispatchPointer = (type: string, pointerId: number, clientY: number) => {
|
||||
const event = new MouseEvent(type, {
|
||||
button: 0,
|
||||
clientX: width / 2,
|
||||
clientY,
|
||||
bubbles: true,
|
||||
})
|
||||
Object.defineProperty(event, 'pointerId', { value: pointerId })
|
||||
overlay.element.dispatchEvent(event)
|
||||
}
|
||||
|
||||
dispatchPointer('pointerdown', 41, height * 0.75)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(true)
|
||||
|
||||
await wrapper.setProps({ data: gridSeries(1) })
|
||||
await flushPromises()
|
||||
dispatchPointer('pointerup', 41, height * 0.75)
|
||||
await flushPromises()
|
||||
|
||||
expect(releasePointerCapture).toHaveBeenCalledWith(41)
|
||||
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(false)
|
||||
|
||||
dispatchPointer('pointerdown', 42, height / 2)
|
||||
dispatchPointer('pointermove', 42, height / 2 + 20)
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('cleans an active gesture when its overlay leaves the DOM', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 1, y: 1 },
|
||||
],
|
||||
},
|
||||
{ displayMode: 'separated' },
|
||||
)
|
||||
const overlay = wrapper.get('.waveform-chart__overlay--shared')
|
||||
const width = Number(overlay.attributes('width'))
|
||||
const height = Number(overlay.attributes('height'))
|
||||
const releasePointerCapture = vi.fn(() => {
|
||||
throw new DOMException('Pointer capture is no longer available', 'NotFoundError')
|
||||
})
|
||||
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, top: 0, width, height }),
|
||||
})
|
||||
Object.defineProperty(overlay.element, 'setPointerCapture', { value: vi.fn() })
|
||||
Object.defineProperty(overlay.element, 'releasePointerCapture', {
|
||||
value: releasePointerCapture,
|
||||
})
|
||||
|
||||
const down = new MouseEvent('pointerdown', {
|
||||
button: 0,
|
||||
clientX: width / 2,
|
||||
clientY: height / 2,
|
||||
bubbles: true,
|
||||
})
|
||||
Object.defineProperty(down, 'pointerId', { value: 43 })
|
||||
overlay.element.dispatchEvent(down)
|
||||
await wrapper.setProps({ hiddenSeriesIds: ['series-0'] })
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.waveform-chart__overlay--shared').exists()).toBe(false)
|
||||
const view = wrapper.findComponent(WaveformChartView)
|
||||
const finishViewportDrag = (
|
||||
view.vm as unknown as { finishViewportDrag: (event: PointerEvent) => void }
|
||||
).finishViewportDrag
|
||||
const up = new MouseEvent('pointerup', {
|
||||
clientX: width / 2,
|
||||
clientY: height / 2,
|
||||
bubbles: true,
|
||||
})
|
||||
Object.defineProperty(up, 'pointerId', { value: 43 })
|
||||
finishViewportDrag(up as unknown as PointerEvent)
|
||||
await flushPromises()
|
||||
|
||||
expect(releasePointerCapture).toHaveBeenCalledWith(43)
|
||||
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(false)
|
||||
|
||||
await wrapper.setProps({ hiddenSeriesIds: [] })
|
||||
await flushPromises()
|
||||
const nextOverlay = wrapper.get('.waveform-chart__overlay--shared')
|
||||
Object.defineProperty(nextOverlay.element, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, top: 0, width, height }),
|
||||
})
|
||||
const nextDown = new MouseEvent('pointerdown', {
|
||||
button: 0,
|
||||
clientX: width / 2,
|
||||
clientY: height / 2,
|
||||
bubbles: true,
|
||||
})
|
||||
Object.defineProperty(nextDown, 'pointerId', { value: 44 })
|
||||
nextOverlay.element.dispatchEvent(nextDown)
|
||||
const nextMove = new MouseEvent('pointermove', {
|
||||
clientX: width * 0.75,
|
||||
clientY: height / 2,
|
||||
bubbles: true,
|
||||
})
|
||||
Object.defineProperty(nextMove, 'pointerId', { value: 44 })
|
||||
nextOverlay.element.dispatchEvent(nextMove)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
114
src/components/waveformChartCases/wheelZoomOut.test.ts
Normal file
114
src/components/waveformChartCases/wheelZoomOut.test.ts
Normal file
@@ -0,0 +1,114 @@
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { flushAnimationFrames } from '../../test/setup'
|
||||
import { mountSizedChart } from '../../test/waveformChart'
|
||||
|
||||
describe('WaveformChart wheel zoom out', () => {
|
||||
it('restores an independent initial domain after replacing data with a narrow window', async () => {
|
||||
const createData = (start: number, end: number) => ({
|
||||
kind: 'series' as const,
|
||||
series: [
|
||||
{
|
||||
id: 'channel-a',
|
||||
name: '通道 A',
|
||||
data: {
|
||||
kind: 'points' as const,
|
||||
points: [
|
||||
{ x: start, y: 0 },
|
||||
{ x: end, y: 1 },
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const wrapper = await mountSizedChart(createData(-5, 5), {
|
||||
displayMode: 'independent',
|
||||
initialXDomain: [-5, 5],
|
||||
})
|
||||
const overlay = wrapper.get('.waveform-chart__overlay--independent')
|
||||
const overlayWidth = Number(overlay.attributes('width'))
|
||||
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, top: 0, width: overlayWidth, height: 290 }),
|
||||
})
|
||||
const dispatchWheel = async (deltaY: number) => {
|
||||
overlay.element.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaY,
|
||||
clientX: overlayWidth / 2,
|
||||
clientY: 145,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
}
|
||||
const endpoints = () => ({
|
||||
start: wrapper.get('.waveform-chart__axis-endpoint--start').text(),
|
||||
end: wrapper.get('.waveform-chart__axis-endpoint--end').text(),
|
||||
})
|
||||
|
||||
expect(endpoints()).toEqual({ start: '-5000', end: '5000' })
|
||||
await dispatchWheel(-4000)
|
||||
expect(endpoints()).not.toEqual({ start: '-5000', end: '5000' })
|
||||
|
||||
await wrapper.setProps({ data: createData(-0.125, 0.125) })
|
||||
await flushPromises()
|
||||
await dispatchWheel(4000)
|
||||
|
||||
expect(endpoints()).toEqual({ start: '-5000', end: '5000' })
|
||||
})
|
||||
|
||||
it('emits one zoom-end payload after zooming a shared viewport out', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const wrapper = await mountSizedChart({
|
||||
kind: 'points',
|
||||
points: [
|
||||
{ x: 0, y: 0 },
|
||||
{ x: 2, y: 1 },
|
||||
],
|
||||
})
|
||||
const overlay = wrapper.get('.waveform-chart__overlay')
|
||||
const overlayWidth = Number(overlay.attributes('width'))
|
||||
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||
value: () => ({ left: 0, top: 0, width: overlayWidth, height: 290 }),
|
||||
})
|
||||
const dispatchWheel = (deltaY: number) =>
|
||||
overlay.element.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaY,
|
||||
clientX: overlayWidth / 2,
|
||||
clientY: 145,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
|
||||
dispatchWheel(-4000)
|
||||
flushAnimationFrames()
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await flushPromises()
|
||||
const zoomedDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||
const zoomedSpan = zoomedDomain[1] - zoomedDomain[0]
|
||||
|
||||
dispatchWheel(1000)
|
||||
flushAnimationFrames()
|
||||
expect(wrapper.emitted('zoom-end')).toHaveLength(1)
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
await flushPromises()
|
||||
|
||||
const zoomedOutPayload = wrapper.emitted('zoom-end')?.at(-1)?.[0] as {
|
||||
start: number
|
||||
end: number
|
||||
gesture: string
|
||||
}
|
||||
expect(wrapper.emitted('zoom-end')).toHaveLength(2)
|
||||
expect(zoomedOutPayload.end - zoomedOutPayload.start).toBeGreaterThan(zoomedSpan)
|
||||
expect(zoomedOutPayload.gesture).toBe('wheel')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
248
src/components/waveformChartCases/zoomPointConstraints.test.ts
Normal file
248
src/components/waveformChartCases/zoomPointConstraints.test.ts
Normal file
@@ -0,0 +1,248 @@
|
||||
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { flushAnimationFrames } from '../../test/setup'
|
||||
import { mountSizedChart } from '../../test/waveformChart'
|
||||
|
||||
const regularPoints = Array.from({ length: 1_200 }, (_, index) => ({ x: index, y: index % 11 }))
|
||||
|
||||
function visibleXCount(domain: [number, number], values = regularPoints): number {
|
||||
return new Set(
|
||||
values.filter((point) => point.x >= domain[0] && point.x <= domain[1]).map((point) => point.x),
|
||||
).size
|
||||
}
|
||||
|
||||
function prepareOverlay(wrapper: VueWrapper, selector: string) {
|
||||
const overlay = wrapper.get(selector)
|
||||
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 { overlay, width, height }
|
||||
}
|
||||
|
||||
async function dispatchWheel(
|
||||
overlay: ReturnType<VueWrapper['get']>,
|
||||
width: number,
|
||||
height: number,
|
||||
deltaY: number,
|
||||
) {
|
||||
overlay.element.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaY,
|
||||
clientX: width / 2,
|
||||
clientY: height / 2,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
function dispatchBox(
|
||||
overlay: ReturnType<VueWrapper['get']>,
|
||||
startX: number,
|
||||
endX: number,
|
||||
height: number,
|
||||
pointerId: number,
|
||||
) {
|
||||
for (const [type, clientX] of [
|
||||
['pointerdown', startX],
|
||||
['pointermove', endX],
|
||||
['pointerup', endX],
|
||||
] as const) {
|
||||
const event = new MouseEvent(type, {
|
||||
button: 0,
|
||||
clientX,
|
||||
clientY: height / 2,
|
||||
bubbles: true,
|
||||
})
|
||||
Object.defineProperty(event, 'pointerId', { value: pointerId })
|
||||
overlay.element.dispatchEvent(event)
|
||||
}
|
||||
}
|
||||
|
||||
describe('WaveformChart point-aware zoom constraints', () => {
|
||||
it('wheel-zooms 1,200 samples to two distinct X values, stops, and still zooms out', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{ kind: 'points', points: regularPoints },
|
||||
{ minVisiblePoints: 2 },
|
||||
)
|
||||
const { overlay, width, height } = prepareOverlay(
|
||||
wrapper,
|
||||
'.waveform-chart__overlay--independent',
|
||||
)
|
||||
|
||||
await dispatchWheel(overlay, width, height, -4_000)
|
||||
await dispatchWheel(overlay, width, height, -4_000)
|
||||
const twoPointDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||
expect(visibleXCount(twoPointDomain)).toBe(2)
|
||||
|
||||
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
|
||||
await dispatchWheel(overlay, width, height, -1_000)
|
||||
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount)
|
||||
|
||||
await dispatchWheel(overlay, width, height, 4_000)
|
||||
const partiallyZoomedOut = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||
expect(partiallyZoomedOut[1] - partiallyZoomedOut[0]).toBeGreaterThan(
|
||||
twoPointDomain[1] - twoPointDomain[0],
|
||||
)
|
||||
await dispatchWheel(overlay, width, height, 4_000)
|
||||
const fullyZoomedOut = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||
expect(fullyZoomedOut).toEqual([0, 1_199])
|
||||
})
|
||||
|
||||
it('keeps two fractional samples when the wheel focus falls between sample positions', async () => {
|
||||
const points = Array.from({ length: 1_000 }, (_, index) => ({
|
||||
x: -5 + (index * 10) / 999,
|
||||
y: index,
|
||||
}))
|
||||
const wrapper = await mountSizedChart(
|
||||
{ kind: 'points', points },
|
||||
{ minVisiblePoints: 2, initialXDomain: [-5, 5] },
|
||||
)
|
||||
const { overlay, width, height } = prepareOverlay(
|
||||
wrapper,
|
||||
'.waveform-chart__overlay--independent',
|
||||
)
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
overlay.element.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaY: -4_000,
|
||||
clientX: width * 0.5032,
|
||||
clientY: height / 2,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||
expect(visibleXCount(domain, points)).toBe(2)
|
||||
})
|
||||
|
||||
it('uses repeated box zooms to reach exactly two distinct X values', async () => {
|
||||
const wrapper = await mountSizedChart(
|
||||
{ kind: 'points', points: regularPoints },
|
||||
{ minVisiblePoints: 2 },
|
||||
)
|
||||
const { overlay, width, height } = prepareOverlay(
|
||||
wrapper,
|
||||
'.waveform-chart__overlay--independent',
|
||||
)
|
||||
|
||||
for (let index = 0; index < 3; index += 1) {
|
||||
dispatchBox(overlay, width / 2 - 4, width / 2 + 4, height, index + 1)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||
expect(visibleXCount(domain)).toBe(2)
|
||||
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
|
||||
dispatchBox(overlay, width / 2 - 4, width / 2 + 4, height, 10)
|
||||
await flushPromises()
|
||||
const constrainedDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount)
|
||||
expect(visibleXCount(constrainedDomain)).toBe(2)
|
||||
})
|
||||
|
||||
it('retains the default 40x maximum when no explicit limit is configured', async () => {
|
||||
const wrapper = await mountSizedChart({ kind: 'points', points: regularPoints })
|
||||
const { overlay, width, height } = prepareOverlay(
|
||||
wrapper,
|
||||
'.waveform-chart__overlay--independent',
|
||||
)
|
||||
|
||||
await dispatchWheel(overlay, width, height, -4_000)
|
||||
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||
expect(domain[1] - domain[0]).toBeCloseTo(1_199 / 40)
|
||||
})
|
||||
|
||||
it('retains two endpoint samples while replacing an independent data window', async () => {
|
||||
const sourcePoints = Array.from({ length: 1_000 }, (_, index) => ({
|
||||
x: -5 + (index * 10) / 999,
|
||||
y: index,
|
||||
}))
|
||||
const wrapper = await mountSizedChart(
|
||||
{ kind: 'points', points: sourcePoints },
|
||||
{ minVisiblePoints: 2, initialXDomain: [-5, 5] },
|
||||
)
|
||||
const { overlay, width, height } = prepareOverlay(
|
||||
wrapper,
|
||||
'.waveform-chart__overlay--independent',
|
||||
)
|
||||
for (let index = 0; index < 10; index += 1) {
|
||||
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
|
||||
overlay.element.dispatchEvent(
|
||||
new WheelEvent('wheel', {
|
||||
deltaY: -1_000,
|
||||
clientX: width * 0.5032,
|
||||
clientY: height / 2,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
)
|
||||
flushAnimationFrames()
|
||||
await flushPromises()
|
||||
if ((wrapper.emitted('zoom-change')?.length ?? 0) === eventCount) break
|
||||
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||
const windowPoints = sourcePoints.filter(
|
||||
(point) => point.x >= domain[0] && point.x <= domain[1],
|
||||
)
|
||||
await wrapper.setProps({ data: { kind: 'points', points: windowPoints } })
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
const finalDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||
const endpointDomain = wrapper
|
||||
.findAll('.waveform-chart__axis-endpoint')
|
||||
.slice(0, 2)
|
||||
.map((endpoint) => Number(endpoint.text()) / 1_000) as [number, number]
|
||||
expect(visibleXCount(finalDomain, sourcePoints)).toBe(2)
|
||||
expect(visibleXCount(endpointDomain, sourcePoints)).toBe(2)
|
||||
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
|
||||
await dispatchWheel(overlay, width, height, -1_000)
|
||||
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount)
|
||||
})
|
||||
|
||||
it('recomputes irregular point constraints for shared tracks after data replacement', async () => {
|
||||
const createData = (offset: number) => ({
|
||||
kind: 'series' as const,
|
||||
series: [
|
||||
{
|
||||
id: 'first',
|
||||
name: 'First',
|
||||
data: {
|
||||
kind: 'points' as const,
|
||||
points: [0, 1, 4, 20].map((x) => ({ x: x + offset, y: x })),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'second',
|
||||
name: 'Second',
|
||||
data: {
|
||||
kind: 'points' as const,
|
||||
points: [0, 3, 9, 20].map((x) => ({ x: x + offset, y: x })),
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
||||
const wrapper = await mountSizedChart(createData(0), {
|
||||
displayMode: 'separated',
|
||||
minVisiblePoints: 2,
|
||||
})
|
||||
const { overlay, width, height } = prepareOverlay(wrapper, '.waveform-chart__overlay')
|
||||
|
||||
await dispatchWheel(overlay, width, height, -4_000)
|
||||
await wrapper.setProps({ data: createData(100) })
|
||||
await flushPromises()
|
||||
await dispatchWheel(overlay, width, height, 4_000)
|
||||
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||
expect(domain).toEqual([100, 120])
|
||||
})
|
||||
})
|
||||
@@ -3,6 +3,10 @@ import { bisector } from 'd3'
|
||||
import type { WaveformPoint } from '@/types'
|
||||
import { resolveWaveformPointErrors } from './data'
|
||||
import type { ResolvedWaveformRenderingOptions } from './renderingOptions'
|
||||
import {
|
||||
resolveRenderablePointSelectionStrategy,
|
||||
type VisiblePointRange,
|
||||
} from './renderingStrategies'
|
||||
|
||||
export {
|
||||
DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||
@@ -13,11 +17,6 @@ export {
|
||||
const pointBisector = bisector((point: WaveformPoint) => point.x)
|
||||
const acceptAllPoints = () => true
|
||||
|
||||
interface VisiblePointRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
interface PointSeriesSource {
|
||||
points: WaveformPoint[]
|
||||
}
|
||||
@@ -65,10 +64,6 @@ export function hasMinimumVisibleXValues(
|
||||
return false
|
||||
}
|
||||
|
||||
function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefined) {
|
||||
if (point && target[target.length - 1] !== point) target.push(point)
|
||||
}
|
||||
|
||||
function selectRenderablePointsInRange(
|
||||
points: WaveformPoint[],
|
||||
range: VisiblePointRange,
|
||||
@@ -76,82 +71,17 @@ function selectRenderablePointsInRange(
|
||||
width: number,
|
||||
options: ResolvedWaveformRenderingOptions,
|
||||
): WaveformPoint[] {
|
||||
const domainStart = Math.min(domain[0], domain[1])
|
||||
const domainEnd = Math.max(domain[0], domain[1])
|
||||
const start = Math.max(0, range.start - 1)
|
||||
const end = Math.min(points.length, range.end + 1)
|
||||
const visibleCount = end - start
|
||||
if (visibleCount <= 0) return []
|
||||
if (!options.downsample || visibleCount <= options.downsampleThreshold) {
|
||||
return points.slice(start, end)
|
||||
}
|
||||
|
||||
const maximumPointCount = Math.max(4, Math.floor(width * options.maxPointsPerPixel))
|
||||
const bucketCount = Math.max(1, Math.floor(maximumPointCount / 4))
|
||||
if (visibleCount <= maximumPointCount) return points.slice(start, end)
|
||||
|
||||
const result: WaveformPoint[] = []
|
||||
const span = domainEnd - domainStart || 1
|
||||
const bucketIndexes = Array.from({ length: 4 }, () => -1)
|
||||
let activeBucket = -1
|
||||
let firstIndex = -1
|
||||
let lastIndex = -1
|
||||
let minimumIndex = -1
|
||||
let maximumIndex = -1
|
||||
|
||||
const addBucketIndex = (index: number, count: number) => {
|
||||
if (index < 0) return count
|
||||
for (let position = 0; position < count; position += 1) {
|
||||
if (bucketIndexes[position] === index) return count
|
||||
}
|
||||
bucketIndexes[count] = index
|
||||
return count + 1
|
||||
}
|
||||
|
||||
const flushBucket = () => {
|
||||
if (firstIndex < 0) return
|
||||
let count = 0
|
||||
count = addBucketIndex(firstIndex, count)
|
||||
count = addBucketIndex(minimumIndex, count)
|
||||
count = addBucketIndex(maximumIndex, count)
|
||||
count = addBucketIndex(lastIndex, count)
|
||||
for (let index = 1; index < count; index += 1) {
|
||||
const value = bucketIndexes[index]
|
||||
let position = index - 1
|
||||
while (position >= 0 && bucketIndexes[position] > value) {
|
||||
bucketIndexes[position + 1] = bucketIndexes[position]
|
||||
position -= 1
|
||||
}
|
||||
bucketIndexes[position + 1] = value
|
||||
}
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
pushUniquePoint(result, points[bucketIndexes[index]])
|
||||
}
|
||||
}
|
||||
|
||||
pushUniquePoint(result, points[start])
|
||||
for (let index = range.start; index < range.end; index += 1) {
|
||||
const point = points[index]
|
||||
const bucket = Math.min(
|
||||
bucketCount - 1,
|
||||
Math.max(0, Math.floor(((point.x - domainStart) / span) * bucketCount)),
|
||||
)
|
||||
if (bucket !== activeBucket) {
|
||||
flushBucket()
|
||||
activeBucket = bucket
|
||||
firstIndex = index
|
||||
lastIndex = index
|
||||
minimumIndex = index
|
||||
maximumIndex = index
|
||||
continue
|
||||
}
|
||||
lastIndex = index
|
||||
if (point.y < points[minimumIndex].y) minimumIndex = index
|
||||
if (point.y > points[maximumIndex].y) maximumIndex = index
|
||||
}
|
||||
flushBucket()
|
||||
pushUniquePoint(result, points[end - 1])
|
||||
return result
|
||||
return resolveRenderablePointSelectionStrategy({ visibleCount, width, options })({
|
||||
points,
|
||||
range,
|
||||
domain,
|
||||
width,
|
||||
options,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
90
src/core/renderingStrategies.test.ts
Normal file
90
src/core/renderingStrategies.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { WaveformPoint } from '../types'
|
||||
import { DEFAULT_WAVEFORM_RENDERING_OPTIONS } from './renderingOptions'
|
||||
import {
|
||||
peakPreservingPointSelectionStrategy,
|
||||
resolveRenderablePointSelectionStrategy,
|
||||
type RenderablePointSelectionContext,
|
||||
} from './renderingStrategies'
|
||||
|
||||
const denseOptions = {
|
||||
...DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||
downsampleThreshold: 0,
|
||||
maxPointsPerPixel: 1,
|
||||
}
|
||||
|
||||
describe('renderable point selection strategies', () => {
|
||||
it('resolves complete-point selection at the configured boundaries', () => {
|
||||
const context: RenderablePointSelectionContext = {
|
||||
points: [
|
||||
{ x: 0, y: 5 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 2, y: 10 },
|
||||
{ x: 3, y: 3 },
|
||||
{ x: 4, y: 7 },
|
||||
],
|
||||
range: { start: 0, end: 5 },
|
||||
domain: [0, 4],
|
||||
width: 100,
|
||||
options: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||
}
|
||||
const complete = resolveRenderablePointSelectionStrategy({
|
||||
visibleCount: 100,
|
||||
width: 100,
|
||||
options: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||
})(context)
|
||||
const disabled = resolveRenderablePointSelectionStrategy({
|
||||
visibleCount: 10_000,
|
||||
width: 100,
|
||||
options: { ...denseOptions, downsample: false },
|
||||
})(context)
|
||||
|
||||
expect(complete).toEqual(context.points)
|
||||
expect(disabled).toEqual(context.points)
|
||||
})
|
||||
|
||||
it('resolves peak-preserving selection for dense visible data', () => {
|
||||
const context: RenderablePointSelectionContext = {
|
||||
points: [
|
||||
{ x: 0, y: 5 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 2, y: 10 },
|
||||
{ x: 3, y: 3 },
|
||||
{ x: 4, y: 7 },
|
||||
],
|
||||
range: { start: 0, end: 5 },
|
||||
domain: [0, 4],
|
||||
width: 4,
|
||||
options: denseOptions,
|
||||
}
|
||||
const selected = resolveRenderablePointSelectionStrategy({
|
||||
visibleCount: 1_000,
|
||||
width: 4,
|
||||
options: denseOptions,
|
||||
})(context)
|
||||
|
||||
expect(selected).toEqual(expect.arrayContaining([context.points[1], context.points[2]]))
|
||||
})
|
||||
|
||||
it('retains first, last, minimum, and maximum points in a peak bucket', () => {
|
||||
const points: WaveformPoint[] = [
|
||||
{ x: 0, y: 5 },
|
||||
{ x: 1, y: 1 },
|
||||
{ x: 2, y: 10 },
|
||||
{ x: 3, y: 3 },
|
||||
{ x: 4, y: 7 },
|
||||
]
|
||||
const selected = peakPreservingPointSelectionStrategy({
|
||||
points,
|
||||
range: { start: 0, end: points.length },
|
||||
domain: [0, 4],
|
||||
width: 4,
|
||||
options: denseOptions,
|
||||
})
|
||||
|
||||
expect(selected[0]).toBe(points[0])
|
||||
expect(selected.at(-1)).toBe(points.at(-1))
|
||||
expect(selected.map((point) => point.y)).toEqual(expect.arrayContaining([1, 10]))
|
||||
})
|
||||
})
|
||||
131
src/core/renderingStrategies.ts
Normal file
131
src/core/renderingStrategies.ts
Normal file
@@ -0,0 +1,131 @@
|
||||
import type { WaveformPoint } from '../types'
|
||||
import type { ResolvedWaveformRenderingOptions } from './renderingOptions'
|
||||
|
||||
export interface VisiblePointRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface RenderablePointSelectionContext {
|
||||
points: WaveformPoint[]
|
||||
range: VisiblePointRange
|
||||
domain: [number, number]
|
||||
width: number
|
||||
options: ResolvedWaveformRenderingOptions
|
||||
}
|
||||
|
||||
export type RenderablePointSelectionStrategy = (
|
||||
context: RenderablePointSelectionContext,
|
||||
) => WaveformPoint[]
|
||||
|
||||
function selectionBounds(range: VisiblePointRange, pointCount: number) {
|
||||
return {
|
||||
start: Math.max(0, range.start - 1),
|
||||
end: Math.min(pointCount, range.end + 1),
|
||||
}
|
||||
}
|
||||
|
||||
function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefined) {
|
||||
if (point && target[target.length - 1] !== point) target.push(point)
|
||||
}
|
||||
|
||||
export const completePointSelectionStrategy: RenderablePointSelectionStrategy = (context) => {
|
||||
const { start, end } = selectionBounds(context.range, context.points.length)
|
||||
return context.points.slice(start, end)
|
||||
}
|
||||
|
||||
export const peakPreservingPointSelectionStrategy: RenderablePointSelectionStrategy = (context) => {
|
||||
const { points, range, domain, width, options } = context
|
||||
const { start, end } = selectionBounds(range, points.length)
|
||||
const visibleCount = end - start
|
||||
if (visibleCount <= 0) return []
|
||||
|
||||
const domainStart = Math.min(domain[0], domain[1])
|
||||
const domainEnd = Math.max(domain[0], domain[1])
|
||||
const maximumPointCount = Math.max(4, Math.floor(width * options.maxPointsPerPixel))
|
||||
const bucketCount = Math.max(1, Math.floor(maximumPointCount / 4))
|
||||
const result: WaveformPoint[] = []
|
||||
const span = domainEnd - domainStart || 1
|
||||
const bucketIndexes = Array.from({ length: 4 }, () => -1)
|
||||
let activeBucket = -1
|
||||
let firstIndex = -1
|
||||
let lastIndex = -1
|
||||
let minimumIndex = -1
|
||||
let maximumIndex = -1
|
||||
|
||||
const addBucketIndex = (index: number, count: number) => {
|
||||
if (index < 0) return count
|
||||
for (let position = 0; position < count; position += 1) {
|
||||
if (bucketIndexes[position] === index) return count
|
||||
}
|
||||
bucketIndexes[count] = index
|
||||
return count + 1
|
||||
}
|
||||
|
||||
const flushBucket = () => {
|
||||
if (firstIndex < 0) return
|
||||
let count = 0
|
||||
count = addBucketIndex(firstIndex, count)
|
||||
count = addBucketIndex(minimumIndex, count)
|
||||
count = addBucketIndex(maximumIndex, count)
|
||||
count = addBucketIndex(lastIndex, count)
|
||||
for (let index = 1; index < count; index += 1) {
|
||||
const value = bucketIndexes[index]
|
||||
let position = index - 1
|
||||
while (position >= 0 && bucketIndexes[position] > value) {
|
||||
bucketIndexes[position + 1] = bucketIndexes[position]
|
||||
position -= 1
|
||||
}
|
||||
bucketIndexes[position + 1] = value
|
||||
}
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
pushUniquePoint(result, points[bucketIndexes[index]])
|
||||
}
|
||||
}
|
||||
|
||||
pushUniquePoint(result, points[start])
|
||||
for (let index = range.start; index < range.end; index += 1) {
|
||||
const point = points[index]
|
||||
const bucket = Math.min(
|
||||
bucketCount - 1,
|
||||
Math.max(0, Math.floor(((point.x - domainStart) / span) * bucketCount)),
|
||||
)
|
||||
if (bucket !== activeBucket) {
|
||||
flushBucket()
|
||||
activeBucket = bucket
|
||||
firstIndex = index
|
||||
lastIndex = index
|
||||
minimumIndex = index
|
||||
maximumIndex = index
|
||||
continue
|
||||
}
|
||||
lastIndex = index
|
||||
if (point.y < points[minimumIndex].y) minimumIndex = index
|
||||
if (point.y > points[maximumIndex].y) maximumIndex = index
|
||||
}
|
||||
flushBucket()
|
||||
pushUniquePoint(result, points[end - 1])
|
||||
return result
|
||||
}
|
||||
|
||||
export interface RenderablePointSelectionStrategyRequest {
|
||||
visibleCount: number
|
||||
width: number
|
||||
options: ResolvedWaveformRenderingOptions
|
||||
}
|
||||
|
||||
export function resolveRenderablePointSelectionStrategy(
|
||||
request: RenderablePointSelectionStrategyRequest,
|
||||
): RenderablePointSelectionStrategy {
|
||||
const maximumPointCount = Math.max(
|
||||
4,
|
||||
Math.floor(request.width * request.options.maxPointsPerPixel),
|
||||
)
|
||||
const shouldUseCompletePoints =
|
||||
!request.options.downsample ||
|
||||
request.visibleCount <= request.options.downsampleThreshold ||
|
||||
request.visibleCount <= maximumPointCount
|
||||
return shouldUseCompletePoints
|
||||
? completePointSelectionStrategy
|
||||
: peakPreservingPointSelectionStrategy
|
||||
}
|
||||
@@ -6,16 +6,15 @@ const END_TIME = 5
|
||||
const TWO_PI = Math.PI * 2
|
||||
|
||||
type SignalGenerator = (time: number, noise: number) => number
|
||||
type ErrorGenerator = (time: number, value: number) => Pick<
|
||||
WaveformPoint,
|
||||
'error' | 'lowerError' | 'upperError'
|
||||
>
|
||||
type ErrorGenerator = (
|
||||
time: number,
|
||||
value: number,
|
||||
) => Pick<WaveformPoint, 'error' | 'lowerError' | 'upperError'>
|
||||
|
||||
interface SimulatedSeriesDefinition
|
||||
extends Pick<
|
||||
WaveformSeries,
|
||||
'id' | 'name' | 'unit' | 'lineType' | 'pointType' | 'errorBar'
|
||||
> {
|
||||
interface SimulatedSeriesDefinition extends Pick<
|
||||
WaveformSeries,
|
||||
'id' | 'name' | 'unit' | 'lineType' | 'pointType' | 'errorBar'
|
||||
> {
|
||||
signal: SignalGenerator
|
||||
errors?: ErrorGenerator
|
||||
}
|
||||
@@ -39,7 +38,7 @@ function createPoints(
|
||||
return {
|
||||
x: time,
|
||||
y: value,
|
||||
...(errors?.(time, value) ?? {}),
|
||||
...errors?.(time, value),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -22,8 +22,7 @@ defineExpose({ resetViewport })
|
||||
v-model:annotations="model.annotations"
|
||||
v-model:hidden-series-ids="model.hiddenSeriesIds"
|
||||
:data="model.data"
|
||||
:min-zoom-span="model.minZoomSpan"
|
||||
:min-visible-points="5"
|
||||
:min-visible-points="2"
|
||||
:initial-x-domain="model.initialXDomain"
|
||||
:display-mode="model.displayMode"
|
||||
:overlay-mode="model.overlayMode"
|
||||
@@ -43,7 +42,9 @@ defineExpose({ resetViewport })
|
||||
:frame-style="model.frameStyle"
|
||||
:axes="model.axes"
|
||||
:clean-view="model.cleanView"
|
||||
:presentation-mode="model.presentationMode"
|
||||
:show-tooltip="model.showTooltip"
|
||||
:plot-margin="model.plotMargin"
|
||||
:zero-line="model.zeroLine"
|
||||
:frame-number="model.frameWatermarkVisible ? 1 : undefined"
|
||||
:annotations-visible="model.annotationsVisible"
|
||||
|
||||
@@ -8,6 +8,35 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="control-section">
|
||||
<h2>绘图区边距</h2>
|
||||
<div class="plot-margin-controls">
|
||||
<label class="frame-style-control">
|
||||
<span>上边距</span>
|
||||
<InputNumber
|
||||
v-model:value="model.plotMarginTop"
|
||||
:min="0"
|
||||
:max="300"
|
||||
:step="1"
|
||||
addon-after="px"
|
||||
size="small"
|
||||
aria-label="绘图区上边距"
|
||||
/>
|
||||
</label>
|
||||
<label class="frame-style-control">
|
||||
<span>下边距</span>
|
||||
<InputNumber
|
||||
v-model:value="model.plotMarginBottom"
|
||||
:min="0"
|
||||
:max="300"
|
||||
:step="1"
|
||||
addon-after="px"
|
||||
size="small"
|
||||
aria-label="绘图区下边距"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="control-section">
|
||||
<div class="control-section__header">
|
||||
<h2>零值参考线</h2>
|
||||
@@ -96,6 +125,76 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
|
||||
<Switch v-model:checked="model.yAxisLineVisible" size="small" aria-label="显示纵轴线" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="x-axis-label-controls">
|
||||
<label class="frame-style-control frame-style-control--switch">
|
||||
<span>自定义 Label</span>
|
||||
<Switch
|
||||
v-model:checked="model.xAxisLabelFormatterEnabled"
|
||||
size="small"
|
||||
aria-label="自定义 X 轴 Label"
|
||||
/>
|
||||
</label>
|
||||
<label v-if="model.xAxisLabelFormatterEnabled" class="frame-style-control">
|
||||
<span>格式类型</span>
|
||||
<Select
|
||||
v-model:value="model.xAxisLabelFormat"
|
||||
:options="model.xAxisLabelFormatOptions"
|
||||
size="small"
|
||||
aria-label="X 轴 Label 格式类型"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'number'"
|
||||
class="frame-style-control"
|
||||
>
|
||||
<span>运算倍率</span>
|
||||
<InputNumber
|
||||
v-model:value="model.xAxisLabelMultiplier"
|
||||
:min="-1000000"
|
||||
:max="1000000"
|
||||
:step="0.1"
|
||||
size="small"
|
||||
aria-label="X 轴 Label 运算倍率"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'number'"
|
||||
class="frame-style-control"
|
||||
>
|
||||
<span>小数位数</span>
|
||||
<InputNumber
|
||||
v-model:value="model.xAxisLabelFractionDigits"
|
||||
:min="0"
|
||||
:max="12"
|
||||
:step="1"
|
||||
size="small"
|
||||
aria-label="X 轴 Label 小数位数"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'datetime'"
|
||||
class="frame-style-control"
|
||||
>
|
||||
<span>时区</span>
|
||||
<Select
|
||||
v-model:value="model.xAxisLabelTimeZone"
|
||||
:options="model.xAxisLabelTimeZoneOptions"
|
||||
size="small"
|
||||
aria-label="X 轴 Label 时区"
|
||||
/>
|
||||
</label>
|
||||
<label
|
||||
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'datetime'"
|
||||
class="frame-style-control frame-style-control--switch"
|
||||
>
|
||||
<span>显示毫秒</span>
|
||||
<Switch
|
||||
v-model:checked="model.xAxisLabelShowMilliseconds"
|
||||
size="small"
|
||||
aria-label="X 轴 Label 显示毫秒"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
</section>
|
||||
<section class="control-section">
|
||||
<h2>图框样式</h2>
|
||||
|
||||
@@ -46,6 +46,10 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
|
||||
<span>净图</span>
|
||||
<Switch v-model:checked="model.cleanView" size="small" aria-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>
|
||||
</section>
|
||||
<section class="control-section">
|
||||
|
||||
29
src/demo/plotMarginControls.test.ts
Normal file
29
src/demo/plotMarginControls.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { InputNumber } from 'ant-design-vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import App from '../App.vue'
|
||||
import { WaveformChart } from '../components'
|
||||
|
||||
describe('plot margin demo controls', () => {
|
||||
it('updates the chart top and bottom margins from the sidebar', async () => {
|
||||
const wrapper = mount(App)
|
||||
await flushPromises()
|
||||
const chart = wrapper.getComponent(WaveformChart)
|
||||
const controls = wrapper.get('.plot-margin-controls').findAllComponents(InputNumber)
|
||||
|
||||
expect(controls).toHaveLength(2)
|
||||
expect(controls[0]?.props('value')).toBe(18)
|
||||
expect(controls[1]?.props('value')).toBe(52)
|
||||
expect(chart.props('plotMargin')).toEqual({ top: 18, bottom: 52 })
|
||||
|
||||
controls[0]?.vm.$emit('update:value', 30)
|
||||
controls[1]?.vm.$emit('update:value', 70)
|
||||
await flushPromises()
|
||||
|
||||
expect(chart.props('plotMargin')).toEqual({ top: 30, bottom: 70 })
|
||||
expect(wrapper.get('.waveform-chart').attributes('data-plot-margin-top')).toBe('30')
|
||||
expect(wrapper.get('.waveform-chart').attributes('data-plot-margin-bottom')).toBe('70')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -10,10 +10,12 @@ import type {
|
||||
WaveformLegendPosition,
|
||||
WaveformLineStyle,
|
||||
WaveformOverlayMode,
|
||||
WaveformPlotMargin,
|
||||
WaveformTitleOptions,
|
||||
WaveformZeroLineOptions,
|
||||
WaveformZoomEndPayload,
|
||||
} from '../components'
|
||||
import type { DemoXAxisLabelFormat, DemoXAxisLabelTimeZone } from './useDemoXAxisLabelControls'
|
||||
|
||||
interface SelectOption<T> {
|
||||
label: string
|
||||
@@ -25,7 +27,10 @@ export interface DemoControlPanelModel {
|
||||
displayMode: WaveformDisplayMode
|
||||
overlayMode: WaveformOverlayMode
|
||||
showTooltip: boolean
|
||||
plotMarginTop: number
|
||||
plotMarginBottom: number
|
||||
cleanView: boolean
|
||||
presentationMode: boolean
|
||||
selectedSeriesId: string
|
||||
selectedLineStyle: WaveformLineStyle
|
||||
lineStyleOptions: Array<SelectOption<WaveformLineStyle>>
|
||||
@@ -43,6 +48,14 @@ export interface DemoControlPanelModel {
|
||||
verticalGridColor: string
|
||||
xAxisLineVisible: boolean
|
||||
yAxisLineVisible: boolean
|
||||
xAxisLabelFormatterEnabled: boolean
|
||||
xAxisLabelFormat: DemoXAxisLabelFormat
|
||||
xAxisLabelFormatOptions: Array<SelectOption<DemoXAxisLabelFormat>>
|
||||
xAxisLabelMultiplier: number
|
||||
xAxisLabelFractionDigits: number
|
||||
xAxisLabelTimeZone: DemoXAxisLabelTimeZone
|
||||
xAxisLabelTimeZoneOptions: Array<SelectOption<DemoXAxisLabelTimeZone>>
|
||||
xAxisLabelShowMilliseconds: boolean
|
||||
frameBorderColor: string
|
||||
frameBackgroundColor: string
|
||||
frameBorderWidth: number
|
||||
@@ -73,7 +86,6 @@ export interface DemoControlPanelModel {
|
||||
|
||||
export interface DemoChartModel {
|
||||
data: WaveformData
|
||||
minZoomSpan?: number
|
||||
initialXDomain?: [number, number]
|
||||
displayMode: WaveformDisplayMode
|
||||
overlayMode: WaveformOverlayMode
|
||||
@@ -87,7 +99,9 @@ export interface DemoChartModel {
|
||||
frameStyle: WaveformFrameStyle
|
||||
axes: WaveformAxesOptions
|
||||
cleanView: boolean
|
||||
presentationMode: boolean
|
||||
showTooltip: boolean
|
||||
plotMargin: WaveformPlotMargin
|
||||
zeroLine: WaveformZeroLineOptions
|
||||
frameWatermarkVisible: boolean
|
||||
annotations: WaveformAnnotation[]
|
||||
|
||||
77
src/demo/useDemoXAxisLabelControls.ts
Normal file
77
src/demo/useDemoXAxisLabelControls.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import type { WaveformXAxisLabelFormatter } from '../types'
|
||||
|
||||
export type DemoXAxisLabelFormat = 'number' | 'datetime'
|
||||
export type DemoXAxisLabelTimeZone = 'local' | 'Asia/Shanghai' | 'UTC'
|
||||
|
||||
const formatOptions = [
|
||||
{ label: '数值', value: 'number' as const },
|
||||
{ label: '固定时间', value: 'datetime' as const },
|
||||
]
|
||||
const timeZoneOptions = [
|
||||
{ label: '中国标准时间', value: 'Asia/Shanghai' as const },
|
||||
{ label: '本地时区', value: 'local' as const },
|
||||
{ label: 'UTC', value: 'UTC' as const },
|
||||
]
|
||||
|
||||
function pad(value: number, length = 2): string {
|
||||
return String(value).padStart(length, '0')
|
||||
}
|
||||
|
||||
export function formatDemoTimestamp(
|
||||
value: number,
|
||||
timeZone: DemoXAxisLabelTimeZone,
|
||||
showMilliseconds: boolean,
|
||||
): string {
|
||||
const offset = timeZone === 'Asia/Shanghai' ? 8 * 60 * 60 * 1000 : 0
|
||||
const date = new Date(value + offset)
|
||||
if (!Number.isFinite(date.getTime())) return String(value)
|
||||
|
||||
const useUtcFields = timeZone !== 'local'
|
||||
const year = useUtcFields ? date.getUTCFullYear() : date.getFullYear()
|
||||
const month = (useUtcFields ? date.getUTCMonth() : date.getMonth()) + 1
|
||||
const day = useUtcFields ? date.getUTCDate() : date.getDate()
|
||||
const hours = useUtcFields ? date.getUTCHours() : date.getHours()
|
||||
const minutes = useUtcFields ? date.getUTCMinutes() : date.getMinutes()
|
||||
const seconds = useUtcFields ? date.getUTCSeconds() : date.getSeconds()
|
||||
const milliseconds = useUtcFields ? date.getUTCMilliseconds() : date.getMilliseconds()
|
||||
const suffix = showMilliseconds ? `.${pad(milliseconds, 3)}` : ''
|
||||
return `${year}-${pad(month)}-${pad(day)} ${pad(hours)}:${pad(minutes)}:${pad(seconds)}${suffix}`
|
||||
}
|
||||
|
||||
export function useDemoXAxisLabelControls() {
|
||||
const xAxisLabelFormatterEnabled = ref(false)
|
||||
const xAxisLabelFormat = ref<DemoXAxisLabelFormat>('number')
|
||||
const xAxisLabelMultiplier = ref(1)
|
||||
const xAxisLabelFractionDigits = ref(3)
|
||||
const xAxisLabelTimeZone = ref<DemoXAxisLabelTimeZone>('Asia/Shanghai')
|
||||
const xAxisLabelShowMilliseconds = ref(false)
|
||||
const xAxisLabelFormatter = computed<WaveformXAxisLabelFormatter | undefined>(() => {
|
||||
if (!xAxisLabelFormatterEnabled.value) return undefined
|
||||
if (xAxisLabelFormat.value === 'datetime') {
|
||||
return (value) =>
|
||||
formatDemoTimestamp(value, xAxisLabelTimeZone.value, xAxisLabelShowMilliseconds.value)
|
||||
}
|
||||
|
||||
const multiplier = Number.isFinite(xAxisLabelMultiplier.value) ? xAxisLabelMultiplier.value : 1
|
||||
const fractionDigits = Number.isFinite(xAxisLabelFractionDigits.value)
|
||||
? Math.min(12, Math.max(0, Math.trunc(xAxisLabelFractionDigits.value)))
|
||||
: 0
|
||||
return (value) => (value * multiplier).toFixed(fractionDigits)
|
||||
})
|
||||
|
||||
return {
|
||||
controlModel: {
|
||||
xAxisLabelFormatterEnabled,
|
||||
xAxisLabelFormat,
|
||||
xAxisLabelFormatOptions: formatOptions,
|
||||
xAxisLabelMultiplier,
|
||||
xAxisLabelFractionDigits,
|
||||
xAxisLabelTimeZone,
|
||||
xAxisLabelTimeZoneOptions: timeZoneOptions,
|
||||
xAxisLabelShowMilliseconds,
|
||||
},
|
||||
xAxisLabelFormatter,
|
||||
}
|
||||
}
|
||||
86
src/demo/xAxisLabelControls.test.ts
Normal file
86
src/demo/xAxisLabelControls.test.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { InputNumber, Select } from 'ant-design-vue'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import App from '../App.vue'
|
||||
import { WaveformChart } from '../components'
|
||||
import { formatDemoTimestamp } from './useDemoXAxisLabelControls'
|
||||
|
||||
describe('X-axis label demo controls', () => {
|
||||
it('formats timestamps in fixed UTC and China-standard-time formats', () => {
|
||||
expect(formatDemoTimestamp(0, 'UTC', false)).toBe('1970-01-01 00:00:00')
|
||||
expect(formatDemoTimestamp(123, 'UTC', true)).toBe('1970-01-01 00:00:00.123')
|
||||
expect(formatDemoTimestamp(0, 'Asia/Shanghai', false)).toBe('1970-01-01 08:00:00')
|
||||
expect(formatDemoTimestamp(Number.NaN, 'UTC', false)).toBe('NaN')
|
||||
})
|
||||
|
||||
it('configures numeric and timestamp labels from the sidebar', async () => {
|
||||
const wrapper = mount(App)
|
||||
await flushPromises()
|
||||
const chart = wrapper.getComponent(WaveformChart)
|
||||
|
||||
expect(chart.props('axes')).toEqual({
|
||||
x: { lineVisible: false },
|
||||
y: { lineVisible: false },
|
||||
})
|
||||
expect(wrapper.find('[aria-label="自定义 X 轴 Label"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="X 轴 Label 小数位数"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="X 轴 Label 格式类型"]').exists()).toBe(false)
|
||||
|
||||
await wrapper.get('[aria-label="自定义 X 轴 Label"]').trigger('click')
|
||||
await flushPromises()
|
||||
const inputNumbers = wrapper.findAllComponents(InputNumber)
|
||||
const multiplierInput = inputNumbers.find((input) =>
|
||||
input.find('[aria-label="X 轴 Label 运算倍率"]').exists(),
|
||||
)
|
||||
const fractionDigitsInput = inputNumbers.find((input) =>
|
||||
input.find('[aria-label="X 轴 Label 小数位数"]').exists(),
|
||||
)
|
||||
expect(multiplierInput).toBeDefined()
|
||||
expect(fractionDigitsInput).toBeDefined()
|
||||
expect(multiplierInput?.props('value')).toBe(1)
|
||||
expect(fractionDigitsInput?.props('value')).toBe(3)
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toMatch(/\.000$/)
|
||||
|
||||
multiplierInput?.vm.$emit('update:value', 2)
|
||||
fractionDigitsInput?.vm.$emit('update:value', 2)
|
||||
await flushPromises()
|
||||
const formatter = chart.props('axes')?.x?.labelFormatter
|
||||
expect(formatter).toBeTypeOf('function')
|
||||
expect(formatter?.(1.234, {} as never)).toBe('2.47')
|
||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toMatch(/\.00$/)
|
||||
|
||||
const formatSelect = wrapper
|
||||
.findAllComponents(Select)
|
||||
.find((select) => select.find('[aria-label="X 轴 Label 格式类型"]').exists())
|
||||
expect(formatSelect?.props('value')).toBe('number')
|
||||
formatSelect?.vm.$emit('update:value', 'datetime')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="X 轴 Label 小数位数"]').exists()).toBe(false)
|
||||
expect(wrapper.find('[aria-label="X 轴 Label 时区"]').exists()).toBe(true)
|
||||
expect(wrapper.find('[aria-label="X 轴 Label 显示毫秒"]').exists()).toBe(true)
|
||||
expect(chart.props('axes')?.x?.labelFormatter?.(0, {} as never)).toBe('1970-01-01 08:00:00')
|
||||
|
||||
await wrapper.get('[aria-label="X 轴 Label 显示毫秒"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(chart.props('axes')?.x?.labelFormatter?.(123, {} as never)).toBe(
|
||||
'1970-01-01 08:00:00.123',
|
||||
)
|
||||
const timeZoneSelect = wrapper
|
||||
.findAllComponents(Select)
|
||||
.find((select) => select.find('[aria-label="X 轴 Label 时区"]').exists())
|
||||
timeZoneSelect?.vm.$emit('update:value', 'UTC')
|
||||
await flushPromises()
|
||||
expect(chart.props('axes')?.x?.labelFormatter?.(123, {} as never)).toBe(
|
||||
'1970-01-01 00:00:00.123',
|
||||
)
|
||||
|
||||
await wrapper.get('[aria-label="自定义 X 轴 Label"]').trigger('click')
|
||||
await flushPromises()
|
||||
expect(chart.props('axes')?.x?.labelFormatter).toBeUndefined()
|
||||
expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
197
src/demoRoutes.css
Normal file
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;
|
||||
}
|
||||
}
|
||||
@@ -17,12 +17,16 @@ export type {
|
||||
WaveformAnnotationStyle,
|
||||
WaveformAnnotation,
|
||||
WaveformRenderingOptions,
|
||||
WaveformPlotMargin,
|
||||
WaveformTitleTextStyle,
|
||||
WaveformTitleOptions,
|
||||
WaveformLegendPosition,
|
||||
WaveformLegendOrientation,
|
||||
WaveformLegendOptions,
|
||||
WaveformFrameStyle,
|
||||
WaveformXAxisLabelKind,
|
||||
WaveformXAxisLabelFormatterContext,
|
||||
WaveformXAxisLabelFormatter,
|
||||
WaveformAxesOptions,
|
||||
WaveformZeroLineOptions,
|
||||
// 数据类型
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { createApp } from 'vue'
|
||||
import 'ant-design-vue/dist/antd.css'
|
||||
|
||||
import App from './App.vue'
|
||||
import DemoRouterApp from './DemoRouterApp.vue'
|
||||
import { router } from './router'
|
||||
import './demoRoutes.css'
|
||||
import './styles.css'
|
||||
|
||||
createApp(App).mount('#app')
|
||||
createApp(DemoRouterApp).use(router).mount('#app')
|
||||
|
||||
21
src/router.ts
Normal file
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);
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -134,6 +133,14 @@ body {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.x-axis-label-controls {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid #eaecf0;
|
||||
}
|
||||
|
||||
.grid-line-color-picker,
|
||||
.grid-line-color-picker .vc-color-wrap {
|
||||
width: 48px;
|
||||
@@ -188,6 +195,7 @@ body {
|
||||
}
|
||||
|
||||
.frame-style-controls,
|
||||
.plot-margin-controls,
|
||||
.auxiliary-style-controls {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
||||
@@ -74,6 +74,14 @@ export interface WaveformRenderingOptions {
|
||||
errorBarMinSpacing?: number
|
||||
}
|
||||
|
||||
/** Pixel margins reserved above and below the waveform plotting area. */
|
||||
export interface WaveformPlotMargin {
|
||||
/** Space between the top of the drawing SVG and the plotting area. Defaults to 18. */
|
||||
top?: number
|
||||
/** Space between the plotting area and the bottom of the drawing SVG. Defaults to 52. */
|
||||
bottom?: number
|
||||
}
|
||||
|
||||
/** Text styling for the chart-level title. */
|
||||
export interface WaveformTitleTextStyle {
|
||||
color?: string
|
||||
@@ -121,10 +129,31 @@ export interface WaveformFrameStyle {
|
||||
backgroundColor?: string
|
||||
}
|
||||
|
||||
export type WaveformXAxisLabelKind = 'tick' | 'start' | 'end'
|
||||
|
||||
/** Context passed to custom X-axis label formatters. */
|
||||
export interface WaveformXAxisLabelFormatterContext {
|
||||
kind: WaveformXAxisLabelKind
|
||||
/** X coordinate in the source data, before time-unit conversion. */
|
||||
rawValue: number
|
||||
timeUnit: 's' | 'ms'
|
||||
/** Current visible X domain in source coordinates. */
|
||||
domain: [number, number]
|
||||
/** Current visible X domain converted to the selected display unit. */
|
||||
displayDomain: [number, number]
|
||||
}
|
||||
|
||||
export type WaveformXAxisLabelFormatter = (
|
||||
value: number,
|
||||
context: WaveformXAxisLabelFormatterContext,
|
||||
) => string
|
||||
|
||||
/** Controls axis baseline visibility while preserving tick marks and axis text. */
|
||||
export interface WaveformAxesOptions {
|
||||
x?: {
|
||||
lineVisible?: boolean
|
||||
/** Formats display-unit X values for ticks and visible-range endpoints. */
|
||||
labelFormatter?: WaveformXAxisLabelFormatter
|
||||
}
|
||||
y?: {
|
||||
lineVisible?: boolean
|
||||
|
||||
@@ -12,12 +12,16 @@ export type {
|
||||
WaveformAnnotationStyle,
|
||||
WaveformAnnotation,
|
||||
WaveformRenderingOptions,
|
||||
WaveformPlotMargin,
|
||||
WaveformTitleTextStyle,
|
||||
WaveformTitleOptions,
|
||||
WaveformLegendPosition,
|
||||
WaveformLegendOrientation,
|
||||
WaveformLegendOptions,
|
||||
WaveformFrameStyle,
|
||||
WaveformXAxisLabelKind,
|
||||
WaveformXAxisLabelFormatterContext,
|
||||
WaveformXAxisLabelFormatter,
|
||||
WaveformAxesOptions,
|
||||
WaveformZeroLineOptions,
|
||||
} from './chart'
|
||||
|
||||
@@ -1,71 +1,113 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { WaveformXAxisLabelFormatter, WaveformXAxisLabelFormatterContext } from '../types'
|
||||
import {
|
||||
formatAnnotationTime,
|
||||
formatAxisTime,
|
||||
formatAxisTimeExponent,
|
||||
formatEndpointTime,
|
||||
formatPlainNumber,
|
||||
formatScientificAxisExponent,
|
||||
formatScientificAxisLabel,
|
||||
formatTooltipNumber,
|
||||
formatTooltipTime,
|
||||
formatXAxisLabel,
|
||||
resolveScientificAxisExponent,
|
||||
shouldUseScientificAxisLabel,
|
||||
} from './formatters'
|
||||
|
||||
describe('waveform number formatters', () => {
|
||||
it('uses the reference Y-axis scientific notation boundaries', () => {
|
||||
expect(shouldUseScientificAxisLabel(0)).toBe(false)
|
||||
expect(shouldUseScientificAxisLabel(0.009)).toBe(true)
|
||||
expect(shouldUseScientificAxisLabel(0.01)).toBe(false)
|
||||
expect(shouldUseScientificAxisLabel(99.99)).toBe(false)
|
||||
expect(shouldUseScientificAxisLabel(100)).toBe(true)
|
||||
expect(shouldUseScientificAxisLabel(0.000999)).toBe(true)
|
||||
expect(shouldUseScientificAxisLabel(0.001)).toBe(false)
|
||||
expect(shouldUseScientificAxisLabel(999.999)).toBe(false)
|
||||
expect(shouldUseScientificAxisLabel(1000)).toBe(true)
|
||||
})
|
||||
|
||||
it('shares one separate exponent across an axis', () => {
|
||||
const positiveAxis = { axisMin: 0, axisMax: 254 }
|
||||
expect(formatScientificAxisLabel(127, positiveAxis)).toBe('1.27')
|
||||
expect(formatScientificAxisLabel(254, positiveAxis)).toBe('2.54')
|
||||
expect(formatScientificAxisExponent(0, 254)).toBe('E+02')
|
||||
it('prefixes one shared exponent to the largest visible tick', () => {
|
||||
const positiveAxis = { axisMin: 1000, axisMax: 3000 }
|
||||
expect(formatScientificAxisLabel(1000, positiveAxis)).toBe('1')
|
||||
expect(formatScientificAxisLabel(2000, positiveAxis)).toBe('2')
|
||||
expect(formatScientificAxisLabel(3000, positiveAxis)).toBe('E+03 3')
|
||||
expect(formatScientificAxisLabel(2000, { ...positiveAxis, topTickValue: 2000 })).toBe('E+03 2')
|
||||
expect(formatScientificAxisExponent(1000, 3000)).toBe('E+03')
|
||||
expect(formatScientificAxisExponent(0, 1e120)).toBe('E+120')
|
||||
|
||||
const tinyAxis = { axisMin: 0, axisMax: 0.0002 }
|
||||
expect(formatScientificAxisLabel(0.0001, tinyAxis)).toBe('1.00')
|
||||
expect(formatScientificAxisLabel(0.0002, tinyAxis)).toBe('2.00')
|
||||
expect(formatScientificAxisExponent(0, 0.0002)).toBe('E-04')
|
||||
|
||||
const negativeAxis = { axisMin: -254, axisMax: 0 }
|
||||
expect(formatScientificAxisLabel(-254, negativeAxis)).toBe('-2.54')
|
||||
expect(formatScientificAxisLabel(0, negativeAxis)).toBe('0.00')
|
||||
expect(formatScientificAxisExponent(-254, 0)).toBe('E+02')
|
||||
const tinyAxis = { axisMin: 0.0001, axisMax: 0.0003 }
|
||||
expect(formatScientificAxisLabel(0.0001, tinyAxis)).toBe('1')
|
||||
expect(formatScientificAxisLabel(0.0002, tinyAxis)).toBe('2')
|
||||
expect(formatScientificAxisLabel(0.0003, tinyAxis)).toBe('E-04 3')
|
||||
expect(formatScientificAxisExponent(0.0001, 0.0003)).toBe('E-04')
|
||||
})
|
||||
|
||||
it('keeps plain axes at two decimals and removes negative zero', () => {
|
||||
expect(formatScientificAxisLabel(99.99, { axisMin: 0, axisMax: 99.99 })).toBe('99.99')
|
||||
expect(formatScientificAxisLabel(0.01, { axisMin: 0, axisMax: 0.01 })).toBe('0.01')
|
||||
expect(formatScientificAxisLabel(-0.001, { axisMin: -1, axisMax: 1 })).toBe('0.00')
|
||||
it('derives the exponent from the largest absolute endpoint', () => {
|
||||
expect(resolveScientificAxisExponent(-9000, -1000)).toBe(3)
|
||||
expect(resolveScientificAxisExponent(-100_000, -3000)).toBe(5)
|
||||
expect(resolveScientificAxisExponent(-10_000, 3000)).toBe(4)
|
||||
expect(resolveScientificAxisExponent(-1000, 0)).toBe(3)
|
||||
expect(resolveScientificAxisExponent(0, 0)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps five significant digits, removes trailing zeroes, and limits decimals to four', () => {
|
||||
expect(formatScientificAxisLabel(123.456, { axisMin: 0, axisMax: 999 })).toBe('123.46')
|
||||
expect(formatScientificAxisLabel(1234.56, { axisMin: 0, axisMax: 9999 })).toBe('1.2346')
|
||||
expect(formatScientificAxisLabel(3000, { axisMin: 1000, axisMax: 3000 })).toBe('E+03 3')
|
||||
expect(formatScientificAxisLabel(-0, { axisMin: -1, axisMax: 1 })).toBe('0')
|
||||
expect(formatScientificAxisLabel(Number.NaN)).toBe('NaN')
|
||||
expect(formatScientificAxisLabel(Number.POSITIVE_INFINITY)).toBe('Infinity')
|
||||
expect(formatScientificAxisExponent(0, 0)).toBeNull()
|
||||
})
|
||||
|
||||
it('formats X-axis ticks and endpoints from the selected display unit', () => {
|
||||
it('formats X-axis ticks and endpoints as complete plain values in the display unit', () => {
|
||||
const domain: [number, number] = [0, 1]
|
||||
expect(formatAxisTime(0.5, 'ms', domain)).toBe('0.50')
|
||||
expect(formatEndpointTime(1, domain, 'ms')).toBe('1.00')
|
||||
expect(formatAxisTimeExponent(domain, 'ms')).toBe('E+03')
|
||||
expect(formatAxisTime(0.5, 's', domain)).toBe('0.50')
|
||||
expect(formatEndpointTime(1, domain, 's')).toBe('1.00')
|
||||
expect(formatAxisTimeExponent(domain, 's')).toBeNull()
|
||||
expect(formatAxisTime(0.5004, 'ms', domain)).toBe('500.4')
|
||||
expect(formatEndpointTime(1, domain, 'ms')).toBe('1000')
|
||||
expect(formatAxisTime(0.5, 's', domain)).toBe('0.5')
|
||||
expect(formatEndpointTime(1, domain, 's')).toBe('1')
|
||||
|
||||
const tinyDomain: [number, number] = [0, 0.000001]
|
||||
expect(formatEndpointTime(0.000001, tinyDomain, 's')).toBe('1.00')
|
||||
expect(formatAxisTimeExponent(tinyDomain, 's')).toBe('E-06')
|
||||
expect(formatEndpointTime(0.000001, tinyDomain, 's')).toBe('0.000001')
|
||||
expect(formatAxisTime(-0.000001, 's', tinyDomain)).toBe('-0.000001')
|
||||
expect(formatAxisTime(-0, 's')).toBe('0')
|
||||
expect(formatAxisTime(1e21, 's')).toBe('1000000000000000000000')
|
||||
expect(formatAxisTime(Number.POSITIVE_INFINITY, 's')).toBe('Infinity')
|
||||
})
|
||||
|
||||
it('passes display values and complete source context to X-axis label formatters', () => {
|
||||
const domain: [number, number] = [0.125, 1.875]
|
||||
const formatter: WaveformXAxisLabelFormatter = (value, context) =>
|
||||
`${context.kind}:${value / 10}`
|
||||
|
||||
expect(formatXAxisLabel(0.5, domain, 'ms', 'tick', formatter)).toBe('tick:50')
|
||||
expect(formatXAxisLabel(domain[0], domain, 'ms', 'start', formatter)).toBe('start:12.5')
|
||||
expect(formatXAxisLabel(domain[1], domain, 'ms', 'end', formatter)).toBe('end:187.5')
|
||||
|
||||
let receivedContext: WaveformXAxisLabelFormatterContext | undefined
|
||||
formatXAxisLabel(0.5, domain, 'ms', 'tick', (value, context) => {
|
||||
receivedContext = context
|
||||
return String(value)
|
||||
})
|
||||
expect(receivedContext).toEqual({
|
||||
kind: 'tick',
|
||||
rawValue: 0.5,
|
||||
timeUnit: 'ms',
|
||||
domain: [0.125, 1.875],
|
||||
displayDomain: [125, 1875],
|
||||
})
|
||||
|
||||
expect(() =>
|
||||
formatXAxisLabel(0.5, domain, 's', 'tick', () => {
|
||||
throw new Error('formatter failed')
|
||||
}),
|
||||
).toThrow('formatter failed')
|
||||
})
|
||||
|
||||
it('formats tooltip and raw values for their display contexts', () => {
|
||||
expect(formatTooltipNumber(12345.67891)).toBe('12,345.6789')
|
||||
expect(formatTooltipNumber(-0)).toBe('0')
|
||||
expect(formatTooltipNumber(Number.POSITIVE_INFINITY)).toBe('Infinity')
|
||||
expect(formatTooltipTime(1.234567, 's')).toBe('1.2346')
|
||||
expect(formatTooltipTime(1, 'ms')).toBe('1,000')
|
||||
expect(formatPlainNumber(0.0000001)).toBe('0.0000001')
|
||||
expect(formatPlainNumber(1e21)).toBe('1000000000000000000000')
|
||||
expect(formatPlainNumber(-0)).toBe('0')
|
||||
|
||||
@@ -1,22 +1,26 @@
|
||||
import type { WaveformXAxisLabelFormatter, WaveformXAxisLabelKind } from '../types'
|
||||
|
||||
/**
|
||||
* 时间单位类型
|
||||
*/
|
||||
export type TimeUnit = 'ms' | 's'
|
||||
|
||||
export interface ScientificAxisLabelOptions {
|
||||
/** @deprecated Y-axis labels always use five significant digits before localization. */
|
||||
precision?: number
|
||||
axisMin?: number
|
||||
axisMax?: number
|
||||
}
|
||||
|
||||
/** @deprecated Use ScientificAxisLabelOptions. */
|
||||
export type ScientificYAxisLabelOptions = ScientificAxisLabelOptions & {
|
||||
topTickValue?: number
|
||||
}
|
||||
|
||||
const DEFAULT_Y_AXIS_PRECISION = 2
|
||||
const SCIENTIFIC_MIN_ABSOLUTE_VALUE = 0.01
|
||||
const SCIENTIFIC_MAX_PLAIN_ABSOLUTE_VALUE = 100
|
||||
/** @deprecated Use ScientificAxisLabelOptions. */
|
||||
export type ScientificYAxisLabelOptions = ScientificAxisLabelOptions
|
||||
|
||||
const SCIENTIFIC_MIN_ABSOLUTE_VALUE = 0.001
|
||||
const SCIENTIFIC_MAX_PLAIN_ABSOLUTE_VALUE = 1000
|
||||
const Y_AXIS_NUMBER_FORMATTER = new Intl.NumberFormat('zh-CN', {
|
||||
maximumFractionDigits: 4,
|
||||
})
|
||||
const TOOLTIP_NUMBER_FORMATTER = new Intl.NumberFormat('zh-CN', {
|
||||
maximumFractionDigits: 4,
|
||||
})
|
||||
@@ -38,9 +42,9 @@ export function shouldUseScientificAxisLabel(maxAbsoluteValue: number): boolean
|
||||
export function resolveScientificAxisExponent(axisMin?: number, axisMax?: number): number | null {
|
||||
if (typeof axisMin !== 'number' || typeof axisMax !== 'number') return null
|
||||
|
||||
const maxAbsoluteValue = Math.max(Math.abs(axisMin), Math.abs(axisMax))
|
||||
return shouldUseScientificAxisLabel(maxAbsoluteValue)
|
||||
? Math.floor(Math.log10(maxAbsoluteValue))
|
||||
const absoluteMaxValue = Math.max(Math.abs(axisMin), Math.abs(axisMax))
|
||||
return shouldUseScientificAxisLabel(absoluteMaxValue)
|
||||
? Math.floor(Math.log10(absoluteMaxValue))
|
||||
: null
|
||||
}
|
||||
|
||||
@@ -49,6 +53,13 @@ function formatExponent(exponent: number): string {
|
||||
return `E${sign}${Math.abs(exponent).toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatYAxisNumber(value: number): string {
|
||||
if (Object.is(value, -0)) return '0'
|
||||
const roundedValue = value === 0 ? 0 : Number(value.toPrecision(5))
|
||||
const formatted = Y_AXIS_NUMBER_FORMATTER.format(roundedValue)
|
||||
return formatted === '-0' ? '0' : formatted
|
||||
}
|
||||
|
||||
/** Format a Y-axis tick, sharing one exponent derived from the complete axis domain. */
|
||||
export function formatScientificAxisLabel(
|
||||
value: number,
|
||||
@@ -56,10 +67,13 @@ export function formatScientificAxisLabel(
|
||||
): string {
|
||||
if (!Number.isFinite(value)) return String(value)
|
||||
|
||||
const precision = options.precision ?? DEFAULT_Y_AXIS_PRECISION
|
||||
const exponent = resolveScientificAxisExponent(options.axisMin, options.axisMax)
|
||||
const scaledValue = exponent === null ? value : value / 10 ** exponent
|
||||
return formatFixedNumber(scaledValue, precision)
|
||||
const formattedValue = formatYAxisNumber(scaledValue)
|
||||
const topTickValue = options.topTickValue ?? options.axisMax
|
||||
return exponent !== null && value === topTickValue
|
||||
? `${formatExponent(exponent)} ${formattedValue}`
|
||||
: formattedValue
|
||||
}
|
||||
|
||||
/** Return the shared E-style multiplier for an axis, or null for a plain axis. */
|
||||
@@ -119,6 +133,26 @@ export function displayTime(value: number, timeUnit: TimeUnit): number {
|
||||
return timeUnit === 'ms' ? value * 1000 : value
|
||||
}
|
||||
|
||||
/** Format an X-axis value without changing its source coordinate. */
|
||||
export function formatXAxisLabel(
|
||||
rawValue: number,
|
||||
domain: [number, number],
|
||||
timeUnit: TimeUnit,
|
||||
kind: WaveformXAxisLabelKind,
|
||||
formatter?: WaveformXAxisLabelFormatter,
|
||||
): string {
|
||||
const value = displayTime(rawValue, timeUnit)
|
||||
if (!formatter) return formatPlainNumber(value)
|
||||
|
||||
return formatter(value, {
|
||||
kind,
|
||||
rawValue,
|
||||
timeUnit,
|
||||
domain: [domain[0], domain[1]],
|
||||
displayDomain: [displayTime(domain[0], timeUnit), displayTime(domain[1], timeUnit)],
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算端点标签的小数位数
|
||||
* @param domain 数据域 [最小值, 最大值]
|
||||
@@ -134,54 +168,33 @@ export function endpointFractionDigits(domain: [number, number], timeUnit: TimeU
|
||||
}
|
||||
|
||||
/**
|
||||
* 按完整 X 轴显示域格式化端点时间
|
||||
* 将 X 轴端点时间格式化为当前显示单位下的普通十进制文本
|
||||
* @param value 时间值(秒)
|
||||
* @param domain 数据域
|
||||
* @param _domain 数据域(为保持现有调用签名而保留)
|
||||
* @param timeUnit 时间单位
|
||||
* @returns 格式化的时间字符串
|
||||
*/
|
||||
export function formatEndpointTime(
|
||||
value: number,
|
||||
domain: [number, number],
|
||||
_domain: [number, number],
|
||||
timeUnit: TimeUnit,
|
||||
): string {
|
||||
const [axisMin, axisMax] = domain.map((domainValue) => displayTime(domainValue, timeUnit)) as [
|
||||
number,
|
||||
number,
|
||||
]
|
||||
return formatScientificAxisLabel(displayTime(value, timeUnit), {
|
||||
axisMin,
|
||||
axisMax,
|
||||
})
|
||||
return formatXAxisLabel(value, _domain, timeUnit, 'end')
|
||||
}
|
||||
|
||||
/**
|
||||
* 按完整 X 轴显示域格式化时间刻度
|
||||
* 将 X 轴时间刻度格式化为当前显示单位下的普通十进制文本
|
||||
* @param value 时间值(秒)
|
||||
* @param timeUnit 时间单位
|
||||
* @param _domain 数据域(为保持现有调用签名而保留)
|
||||
* @returns 格式化的时间字符串
|
||||
*/
|
||||
export function formatAxisTime(
|
||||
value: number,
|
||||
timeUnit: TimeUnit,
|
||||
domain?: [number, number],
|
||||
_domain?: [number, number],
|
||||
): string {
|
||||
const displayValue = displayTime(value, timeUnit)
|
||||
const displayDomain = domain?.map((domainValue) => displayTime(domainValue, timeUnit)) as
|
||||
[number, number] | undefined
|
||||
return formatScientificAxisLabel(displayValue, {
|
||||
axisMin: displayDomain?.[0],
|
||||
axisMax: displayDomain?.[1],
|
||||
})
|
||||
}
|
||||
|
||||
/** Return the shared multiplier for an X-axis domain in its selected display unit. */
|
||||
export function formatAxisTimeExponent(
|
||||
domain: [number, number],
|
||||
timeUnit: TimeUnit,
|
||||
): string | null {
|
||||
const [axisMin, axisMax] = domain.map((value) => displayTime(value, timeUnit)) as [number, number]
|
||||
return formatScientificAxisExponent(axisMin, axisMax)
|
||||
return formatXAxisLabel(value, _domain ?? [value, value], timeUnit, 'tick')
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -191,11 +204,7 @@ export function formatAxisTimeExponent(
|
||||
* @returns 格式化的时间字符串
|
||||
*/
|
||||
export function formatTooltipTime(value: number, timeUnit: TimeUnit): string {
|
||||
const displayValue = displayTime(value, timeUnit)
|
||||
return displayValue.toLocaleString('zh-CN', {
|
||||
minimumFractionDigits: 4,
|
||||
maximumFractionDigits: 4,
|
||||
})
|
||||
return formatTooltipNumber(displayTime(value, timeUnit))
|
||||
}
|
||||
|
||||
/** Format an annotation X coordinate in the selected display time unit. */
|
||||
|
||||
@@ -11,7 +11,7 @@ export {
|
||||
endpointFractionDigits,
|
||||
formatEndpointTime,
|
||||
formatAxisTime,
|
||||
formatAxisTimeExponent,
|
||||
formatXAxisLabel,
|
||||
formatTooltipTime,
|
||||
formatAnnotationTime,
|
||||
formatPlainNumber,
|
||||
|
||||
@@ -37,7 +37,8 @@ export function downsampleLTTB(data: WaveformPoint[], threshold: number): Wavefo
|
||||
|
||||
// 确保阈值至少为 3
|
||||
const sampledLength = Math.max(3, Math.floor(threshold))
|
||||
const sampled: WaveformPoint[] = new Array(sampledLength)
|
||||
const sampled: WaveformPoint[] = []
|
||||
sampled.length = sampledLength
|
||||
|
||||
// 始终保留第一个和最后一个点
|
||||
sampled[0] = data[0]!
|
||||
|
||||
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