Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81f36bde71 | ||
|
|
e940e0af1d | ||
|
|
3d6c5156c0 | ||
|
|
d667350219 |
147
AGENTS.md
147
AGENTS.md
@@ -1,57 +1,130 @@
|
|||||||
# Repository Guidelines
|
# Repository Guidelines
|
||||||
|
|
||||||
## Project Structure & Module Organization
|
## Project Overview
|
||||||
|
|
||||||
This repository is a Vue 3 + TypeScript waveform component library with a Vite demo.
|
This repository is a Vue 3, TypeScript, and D3 waveform component library with a Vite demo.
|
||||||
Production exports are defined in `src/index.ts`; demo entry points are `src/main.ts` and
|
The published package is `waveform-analysis`; its supported public surface is exported from
|
||||||
`src/App.vue`. The main chart is `src/components/WaveformChart.vue`, with focused modules under
|
`src/index.ts`. The demo is a consumer of that library code, not part of the public API.
|
||||||
`src/components/{core,data,rendering,interaction,annotation}`. Shared types live in `src/types`,
|
|
||||||
data normalization and chart logic in `src/core` and `src/utils`, styles in `src/styles.css`, and
|
|
||||||
sample data in `src/data`. Tests are colocated with implementation files (`*.test.ts`), with shared
|
|
||||||
setup in `src/test/setup.ts`. `dist/` and `dist-demo/` are generated; do not edit them.
|
|
||||||
|
|
||||||
## Build, Test, and Development Commands
|
Important behavioral contracts:
|
||||||
|
|
||||||
Use pnpm (the lockfile is `pnpm-lock.yaml`) and Node.js 22 as CI does.
|
- Treat waveform input as immutable. Replace the `data` reference to refresh normalization,
|
||||||
|
domains, caches, and the viewport; do not rely on in-place array mutation.
|
||||||
|
- X coordinates are stored in seconds. `timeUnit` and X-axis formatters affect display only and
|
||||||
|
must not alter raw coordinates, zoom domains, or emitted event values.
|
||||||
|
- Give every multi-series waveform a unique, stable `id`. Visibility, annotations, axes, and
|
||||||
|
state retention use normalized series IDs.
|
||||||
|
- Annotations and hidden-series state are controlled by the consumer. Emit replacement arrays;
|
||||||
|
persistence belongs to the host application.
|
||||||
|
- Rendering may downsample visible SVG geometry, but domains, nearest-point lookup, tooltips,
|
||||||
|
annotations, and error ranges must continue to use the full normalized data.
|
||||||
|
|
||||||
|
## Repository Layout
|
||||||
|
|
||||||
|
- `src/index.ts`: deliberate package exports for components, types, core helpers, and utilities.
|
||||||
|
- `src/components/WaveformChart.vue`: top-level chart composition and public prop/event boundary.
|
||||||
|
- `src/components/core/`: layout, domains, grids, presentation state, and chart controllers.
|
||||||
|
- `src/components/data/`: component-facing data types and data-layer exports.
|
||||||
|
- `src/components/rendering/`: SVG tracks, axes, series, legends, hover layers, and styles.
|
||||||
|
- `src/components/interaction/`: viewport, zoom, hover, tooltip, and interaction hosts.
|
||||||
|
- `src/components/annotation/`: annotation types, serialization, editing, layout, and interaction.
|
||||||
|
- `src/core/`: package-level normalization and rendering/downsampling logic.
|
||||||
|
- `src/types/`: shared public data and chart types.
|
||||||
|
- `src/utils/`: domains, formatting, geometry, sampling, and waveform ID helpers.
|
||||||
|
- `src/demo/`, `src/App.vue`: controls and the main interactive demo workspace.
|
||||||
|
- `src/router.ts`, `src/DemoRouterApp.vue`, `src/views/`: hash-based demo routes and focused demos.
|
||||||
|
- `src/data/`: simulated demo data; `src/test/`: shared Vitest setup and test helpers.
|
||||||
|
- `scripts/`: repository checks and declaration-build cleanup scripts.
|
||||||
|
- `docs/` and root Markdown notes: supporting or historical documentation; verify claims against
|
||||||
|
current source, tests, `README.md`, and `package.json` before relying on them.
|
||||||
|
|
||||||
|
Tests are colocated as `*.test.ts`. The large chart suite is split under
|
||||||
|
`src/components/waveformChartCases/`; add focused cases there instead of rebuilding a monolithic
|
||||||
|
chart test file.
|
||||||
|
|
||||||
|
## Toolchain and Commands
|
||||||
|
|
||||||
|
Use Node.js 22 and pnpm 10.32.1, matching CI. Keep `pnpm-lock.yaml` synchronized with
|
||||||
|
`package.json` and use the locked install in automation.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm install # Install locked dependencies
|
pnpm install --frozen-lockfile # Reproduce the CI dependency graph
|
||||||
pnpm dev # Start the Vite demo server
|
pnpm dev # Start the Vite demo
|
||||||
pnpm typecheck # Run vue-tsc checks
|
pnpm typecheck # Run vue-tsc project checks
|
||||||
|
pnpm check:file-length # Enforce the 400-line limit under src/
|
||||||
pnpm lint # Run ESLint with zero warnings allowed
|
pnpm lint # Run ESLint with zero warnings allowed
|
||||||
pnpm test # Run Vitest once
|
pnpm test # Run Vitest once
|
||||||
pnpm test:coverage # Run tests and enforce coverage thresholds
|
pnpm test:coverage # Run tests and enforce coverage thresholds
|
||||||
pnpm build # Type-check and build library plus demo bundles
|
pnpm build # Type-check and build library, declarations, and demo
|
||||||
pnpm preview # Preview the production demo build
|
pnpm pack --dry-run # Inspect the publishable package contents
|
||||||
|
pnpm preview # Preview dist-demo/
|
||||||
|
pnpm format # Apply the repository Prettier configuration
|
||||||
```
|
```
|
||||||
|
|
||||||
Run `pnpm format` to apply the repository Prettier configuration.
|
For a narrow change, run the closest test file while iterating, then run the full relevant gates
|
||||||
|
before handoff. Do not describe a check as passing unless it actually ran.
|
||||||
|
|
||||||
## Coding Style & Naming Conventions
|
## Coding and Architecture Conventions
|
||||||
|
|
||||||
Use TypeScript and Vue 3 Composition API with two-space indentation, single quotes, no semicolons,
|
Use Vue 3 Composition API and strict TypeScript. Follow the repository Prettier configuration:
|
||||||
and a 100-column print width. Prettier and ESLint are authoritative.
|
two-space indentation, single quotes, no semicolons, and a 100-column print width. Use PascalCase
|
||||||
Use PascalCase for Vue components, component filenames, and types; use camelCase for functions,
|
for Vue components and types, and camelCase for functions, composables, variables, and props in
|
||||||
variables, and composables (for example, `useWaveformData`). Keep public exports deliberate and
|
TypeScript. Vue template props and events use kebab-case.
|
||||||
preserve stable series IDs for multi-channel data.
|
|
||||||
|
|
||||||
## Testing Guidelines
|
ESLint enforces a maximum of 400 physical lines for files under `src/`; the standalone length
|
||||||
|
check applies the same limit to all text files below `src/`. Split code by existing ownership
|
||||||
|
boundaries when a file approaches the limit. Keep rendering, layout, interaction, annotation, and
|
||||||
|
data concerns in their existing modules rather than adding more orchestration to
|
||||||
|
`WaveformChart.vue`.
|
||||||
|
|
||||||
Vitest with `@vue/test-utils` and jsdom is used. Name tests `*.test.ts` beside the
|
Use the `@/` alias for internal `src/` imports where it improves clarity. Keep public exports
|
||||||
code they cover. Exercise normalization, rendering/layout helpers, formatting, and component
|
explicit: adding a type or helper internally does not make it supported API. When changing a
|
||||||
interactions, including empty, non-finite, and multi-series inputs. Coverage thresholds are 80%
|
public prop, event, type, formatter, serialization format, or package export, update `src/index.ts`,
|
||||||
for lines/statements/functions and 75% for branches; run `pnpm test:coverage` before submitting.
|
tests, and `README.md` together. Preserve backwards compatibility unless the task explicitly calls
|
||||||
|
for a breaking change.
|
||||||
|
|
||||||
## Commit & Pull Request Guidelines
|
Do not hand-edit generated output in `dist/`, `dist-demo/`, `coverage/`, or `.vite/`. Library peers
|
||||||
|
are externalized by `vite.lib.config.ts`; validate packaging after dependency or export changes.
|
||||||
|
|
||||||
The current history contains only `first commit`, so no established convention exists yet. Use short,
|
## Testing Expectations
|
||||||
imperative messages, preferably scoped (for example, `feat(chart): ...`, `fix(annotation): ...`, or
|
|
||||||
`test: ...`). Pull requests should explain API or user-visible changes, list verification commands,
|
|
||||||
link an issue or plan, and include screenshots or a short recording for visual changes. Keep generated
|
|
||||||
files and unrelated refactors out of the change.
|
|
||||||
|
|
||||||
## CI and Configuration
|
Vitest runs in jsdom with `@vue/test-utils`; shared setup is in `src/test/setup.ts`. Coverage uses
|
||||||
|
V8 and must remain at least 80% for lines, statements, and functions, and 75% for branches.
|
||||||
|
|
||||||
GitHub Actions runs install, typecheck, lint, coverage, build, and `pnpm pack --dry-run` on pushes and
|
Cover behavior at the narrowest useful layer:
|
||||||
pull requests. Do not commit secrets or local environment files; review the staged file list before
|
|
||||||
opening a pull request.
|
- normalization: empty, invalid, non-finite, unsorted, duplicate-ID, and multi-series inputs;
|
||||||
|
- layout and domains: display modes, overlays, fixed ranges, pagination, margins, and small sizes;
|
||||||
|
- formatting: endpoint/tick consistency, time units, scientific notation, and custom formatters;
|
||||||
|
- rendering: downsampling, styles, points, error bars, axes, grids, legends, and clean view;
|
||||||
|
- interaction: wheel/box zoom, zoom-out, pan, reset, hover, visibility, and presentation mode;
|
||||||
|
- annotations: CRUD, serialization validation, drag offsets, reprojection, and hidden series.
|
||||||
|
|
||||||
|
Avoid brittle assertions against incidental SVG structure when a user-visible or emitted behavior
|
||||||
|
can be asserted instead. Add regression coverage for every bug fix.
|
||||||
|
|
||||||
|
## Build, CI, and Release
|
||||||
|
|
||||||
|
`pnpm build` produces the ESM/CJS library and CSS in `dist/`, declarations in `dist/types/`, and the
|
||||||
|
demo in `dist-demo/`. GitHub CI runs frozen install, typecheck, file-length checks, lint, coverage,
|
||||||
|
build, and `pnpm pack --dry-run` on pushes and pull requests.
|
||||||
|
|
||||||
|
Use short Conventional Commit-style messages consistent with current history, for example
|
||||||
|
`feat(chart): support ...`, `fix(annotation): handle ...`, or `test: cover ...`. Keep generated
|
||||||
|
files, local settings, and unrelated refactors out of commits. Review `git status` and the staged
|
||||||
|
diff before committing. Pull requests should explain public or user-visible effects, list commands
|
||||||
|
actually run, link the relevant issue or plan, and include screenshots or a short recording for
|
||||||
|
visual changes.
|
||||||
|
|
||||||
|
Releases are triggered by annotated tags matching `vX.Y.Z` or a semver prerelease such as
|
||||||
|
`vX.Y.Z-rc.1`. The tag version must exactly match `package.json`. The Gitea workflow validates,
|
||||||
|
tests, builds, packs, publishes to both configured npm registries, creates checksums and a release,
|
||||||
|
and deploys the demo only for stable versions. Do not create or push a release tag until the version
|
||||||
|
commit and full release checks are complete.
|
||||||
|
|
||||||
|
## Change Discipline
|
||||||
|
|
||||||
|
Keep edits scoped to the request and preserve unrelated worktree changes. Do not commit secrets,
|
||||||
|
local environment files, IDE state, logs, or registry credentials. For visual behavior changes,
|
||||||
|
verify both the reusable component and the relevant demo route at representative desktop and small
|
||||||
|
container sizes; state clearly when browser verification was not performed.
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -111,6 +111,7 @@ const data = ref<WaveformData>({
|
|||||||
| `minVisiblePoints` | `number` | `0` | 缩放后至少保留的不同 X 坐标数 |
|
| `minVisiblePoints` | `number` | `0` | 缩放后至少保留的不同 X 坐标数 |
|
||||||
| `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围 |
|
| `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围 |
|
||||||
| `initialXDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置初始范围 |
|
| `initialXDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置初始范围 |
|
||||||
|
| `xDomainStrategy` | `WaveformXDomainStrategy` | `{ type: 'data' }` | 自动 X 轴视口范围策略 |
|
||||||
| `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 |
|
| `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 |
|
||||||
| `yDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置固定范围 |
|
| `yDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置固定范围 |
|
||||||
| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 |
|
| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 |
|
||||||
@@ -129,7 +130,7 @@ const data = ref<WaveformData>({
|
|||||||
|
|
||||||
所有公开类型均可从包入口导入,例如 `WaveformData`、`WaveformSeries`、
|
所有公开类型均可从包入口导入,例如 `WaveformData`、`WaveformSeries`、
|
||||||
`WaveformAnnotation`、`WaveformLineStyle`、`WaveformRenderingOptions`、
|
`WaveformAnnotation`、`WaveformLineStyle`、`WaveformRenderingOptions`、
|
||||||
`WaveformAxesOptions`、`WaveformXAxisLabelFormatter`、`WaveformZeroLineOptions`、
|
`WaveformAxesOptions`、`WaveformXAxisLabelFormatter`、`WaveformXDomainStrategy`、`WaveformZeroLineOptions`、
|
||||||
`WaveformGridOptions` 和 `WaveformGridTrackLines`。
|
`WaveformGridOptions` 和 `WaveformGridTrackLines`。
|
||||||
|
|
||||||
### 数据结构
|
### 数据结构
|
||||||
@@ -252,8 +253,30 @@ X 轴且包含多个轨道时使用按稳定 track ID 索引的 `yRanges`。平
|
|||||||
重置组件内部缩放并触发 `zoom-reset`;调用方应在事件中取消区间请求并恢复首次完整数据。
|
重置组件内部缩放并触发 `zoom-reset`;调用方应在事件中取消区间请求并恢复首次完整数据。
|
||||||
外部重置按钮也可以通过模板引用调用组件公开的 `resetViewport()` 方法,然后执行相同的数据恢复逻辑。
|
外部重置按钮也可以通过模板引用调用组件公开的 `resetViewport()` 方法,然后执行相同的数据恢复逻辑。
|
||||||
|
|
||||||
|
没有显式配置初始范围时,可以通过 `xDomainStrategy` 将数据范围扩展为便于阅读的视口端点。
|
||||||
|
默认的 `{ type: 'data' }` 保持数据最小值和最大值不变;`type: 'nice'` 使用固定刻度数量计算
|
||||||
|
易读边界,且只扩展视口,不修改原始点位、tooltip、标注或缩放事件值:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<WaveformChart
|
||||||
|
:data="chartData"
|
||||||
|
:x-domain-strategy="{ type: 'nice', bounds: 'end', tickCount: 10, includeExplicit: true }"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
例如秒坐标数据范围为 `[0, 4.999999]` 且 `timeUnit='ms'` 时,上述配置会使用
|
||||||
|
`[0, 5]` 作为初始及重置视口,两端 label 显示 `0` 和 `5000`。`bounds: 'end'`
|
||||||
|
只扩展右端;默认的 `bounds: 'both'`
|
||||||
|
会同时扩展两端。`tickCount` 默认为 `10`,只参与边界计算,不随组件宽度变化。
|
||||||
|
`initialXDomains`、`initialXDomain` 的显式配置默认保持原值;仅当 `includeExplicit: true` 时
|
||||||
|
也应用 nice 扩展。独立模式在未配置显式范围时
|
||||||
|
按图框分别计算,共享 X 轴模式则合并所有可见图框后计算。所有范围仍使用原始秒坐标,
|
||||||
|
`timeUnit` 只影响显示。
|
||||||
|
|
||||||
独立坐标模式下,回填响应应只替换 `seriesIds` 对应的系列,并调用
|
独立坐标模式下,回填响应应只替换 `seriesIds` 对应的系列,并调用
|
||||||
`resetViewport(trackIndex)`;其他图框的数据和缩放状态应保持不变。
|
`resetViewport(trackIndex)`;其他图框的数据和缩放状态应保持不变。独立模式下双击图框触发的
|
||||||
|
`zoom-reset` payload 会包含该图框的 `trackIndex` 和 `seriesIds`;共享 X 范围模式的 payload
|
||||||
|
不包含这两个字段,表示全局复位。忽略事件参数的既有监听器可以继续使用。
|
||||||
|
|
||||||
多通道数据应为每个 `WaveformSeries` 提供稳定的 `id`。内部时间坐标始终使用秒,
|
多通道数据应为每个 `WaveformSeries` 提供稳定的 `id`。内部时间坐标始终使用秒,
|
||||||
`timeUnit` 只控制坐标轴和 tooltip 的显示单位。
|
`timeUnit` 只控制坐标轴和 tooltip 的显示单位。
|
||||||
@@ -639,7 +662,7 @@ X 轴刻度和左右端点先按 `timeUnit` 转换为秒或毫秒,再显示为
|
|||||||
| `point-hover` | 当前最近点变化时触发,离开图表时传入 `null` |
|
| `point-hover` | 当前最近点变化时触发,离开图表时传入 `null` |
|
||||||
| `zoom-change` | 缩放过程中触发,参数为 `[start, end]` |
|
| `zoom-change` | 缩放过程中触发,参数为 `[start, end]` |
|
||||||
| `zoom-end` | 滚轮或框选结束后触发;`gesture` 区分二者,独立模式附带轨道信息 |
|
| `zoom-end` | 滚轮或框选结束后触发;`gesture` 区分二者,独立模式附带轨道信息 |
|
||||||
| `zoom-reset` | 双击重置视口时触发,调用方应恢复首次完整数据 |
|
| `zoom-reset` | 双击重置视口时触发;独立模式 payload 标识目标图框 |
|
||||||
| `page-change` | 分页变化,参数为当前页和总页数 |
|
| `page-change` | 分页变化,参数为当前页和总页数 |
|
||||||
| `series-visibility-change` | 图例切换曲线显隐时触发 |
|
| `series-visibility-change` | 图例切换曲线显隐时触发 |
|
||||||
| `annotation-create` / `annotation-update` / `annotation-delete` | 标注新增、更新或删除 |
|
| `annotation-create` / `annotation-update` / `annotation-delete` | 标注新增、更新或删除 |
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "waveform-analysis",
|
"name": "waveform-analysis",
|
||||||
"version": "0.1.31",
|
"version": "0.1.33",
|
||||||
"main": "./dist/index.cjs",
|
"main": "./dist/index.cjs",
|
||||||
"module": "./dist/index.js",
|
"module": "./dist/index.js",
|
||||||
"types": "./dist/types/index.d.ts",
|
"types": "./dist/types/index.d.ts",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ const props = withDefaults(defineProps<WaveformChartProps>(), {
|
|||||||
zoomable: true,
|
zoomable: true,
|
||||||
pannable: false,
|
pannable: false,
|
||||||
minVisiblePoints: 0,
|
minVisiblePoints: 0,
|
||||||
|
xDomainStrategy: () => ({ type: 'data' }),
|
||||||
timeUnit: 'ms',
|
timeUnit: 'ms',
|
||||||
frameNumber: undefined,
|
frameNumber: undefined,
|
||||||
annotations: () => [],
|
annotations: () => [],
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import type {
|
|||||||
WaveformDisplayMode,
|
WaveformDisplayMode,
|
||||||
WaveformOverlayMode,
|
WaveformOverlayMode,
|
||||||
WaveformPoint,
|
WaveformPoint,
|
||||||
|
WaveformXDomainStrategy,
|
||||||
WaveformXAxisLabelFormatter,
|
WaveformXAxisLabelFormatter,
|
||||||
} from '../../types'
|
} from '../../types'
|
||||||
import { buildMinorTicks, formatXAxisLabel } from '../../utils'
|
import { buildMinorTicks, formatXAxisLabel } from '../../utils'
|
||||||
@@ -26,6 +27,7 @@ import {
|
|||||||
} from './grid'
|
} from './grid'
|
||||||
import { axisTextMetrics, resolveYAxisSeriesGroups } from './layout'
|
import { axisTextMetrics, resolveYAxisSeriesGroups } from './layout'
|
||||||
import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
|
import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types'
|
||||||
|
import { applyXDomainStrategy } from './xDomain'
|
||||||
import {
|
import {
|
||||||
Y_AXIS_LABEL_BAND_WIDTH,
|
Y_AXIS_LABEL_BAND_WIDTH,
|
||||||
Y_AXIS_LABEL_GAP,
|
Y_AXIS_LABEL_GAP,
|
||||||
@@ -46,6 +48,7 @@ export interface BuildTrackLayoutsOptions {
|
|||||||
sharedZoomDomain: [number, number]
|
sharedZoomDomain: [number, number]
|
||||||
initialXDomain?: [number, number]
|
initialXDomain?: [number, number]
|
||||||
initialXDomains?: Record<string, [number, number]>
|
initialXDomains?: Record<string, [number, number]>
|
||||||
|
xDomainStrategy?: WaveformXDomainStrategy
|
||||||
fixedYDomain?: [number, number]
|
fixedYDomain?: [number, number]
|
||||||
fixedYDomains?: Record<string, [number, number]>
|
fixedYDomains?: Record<string, [number, number]>
|
||||||
yDomains?: Record<string, [number, number]>
|
yDomains?: Record<string, [number, number]>
|
||||||
@@ -57,6 +60,21 @@ export interface BuildTrackLayoutsOptions {
|
|||||||
showCompactEmptyTracks: boolean
|
showCompactEmptyTracks: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveIndependentXDomain(
|
||||||
|
track: DisplayTrack,
|
||||||
|
seriesId: string,
|
||||||
|
options: BuildTrackLayoutsOptions,
|
||||||
|
): [number, number] {
|
||||||
|
const strategy = options.xDomainStrategy ?? { type: 'data' }
|
||||||
|
const explicitDomain =
|
||||||
|
options.initialXDomains?.[track.id] ??
|
||||||
|
options.initialXDomains?.[seriesId] ??
|
||||||
|
options.initialXDomain
|
||||||
|
return explicitDomain
|
||||||
|
? applyXDomainStrategy(explicitDomain, strategy, true)
|
||||||
|
: applyXDomainStrategy(track.xDomain, strategy)
|
||||||
|
}
|
||||||
|
|
||||||
export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayout[] {
|
export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayout[] {
|
||||||
const visibleCells = options.cells.map((cell) => ({ ...cell, hasSeries: Boolean(cell.series) }))
|
const visibleCells = options.cells.map((cell) => ({ ...cell, hasSeries: Boolean(cell.series) }))
|
||||||
const bottomCells = getBottomRowCellIndexes(visibleCells, options.grid.columnCount)
|
const bottomCells = getBottomRowCellIndexes(visibleCells, options.grid.columnCount)
|
||||||
@@ -88,13 +106,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
|
|||||||
const series = displayTrack.visibleSeries[0] ?? displayTrack.series[0] ?? emptySeries
|
const series = displayTrack.visibleSeries[0] ?? displayTrack.series[0] ?? emptySeries
|
||||||
const baseXScale =
|
const baseXScale =
|
||||||
options.displayMode === 'independent'
|
options.displayMode === 'independent'
|
||||||
? scaleLinear(
|
? scaleLinear(resolveIndependentXDomain(displayTrack, series.id, options), [0, cell.width])
|
||||||
options.initialXDomains?.[displayTrack.id] ??
|
|
||||||
options.initialXDomains?.[series.id] ??
|
|
||||||
options.initialXDomain ??
|
|
||||||
displayTrack.xDomain,
|
|
||||||
[0, cell.width],
|
|
||||||
)
|
|
||||||
: scaleLinear(options.sharedZoomDomain, [0, cell.width])
|
: scaleLinear(options.sharedZoomDomain, [0, cell.width])
|
||||||
const transform =
|
const transform =
|
||||||
options.displayMode === 'independent'
|
options.displayMode === 'independent'
|
||||||
|
|||||||
@@ -204,6 +204,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
() => props.minVisiblePoints,
|
() => props.minVisiblePoints,
|
||||||
() => props.initialXDomain,
|
() => props.initialXDomain,
|
||||||
() => props.initialXDomains,
|
() => props.initialXDomains,
|
||||||
|
() => props.xDomainStrategy,
|
||||||
() => props.displayMode,
|
() => props.displayMode,
|
||||||
() => chartTracks.value.length,
|
() => chartTracks.value.length,
|
||||||
currentPage,
|
currentPage,
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
|
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
|
||||||
import type { PreparedWaveformSeries } from './useWaveformData'
|
import type { PreparedWaveformSeries } from './useWaveformData'
|
||||||
import type { ResolvedWaveformChartProps } from './waveformChartTypes'
|
import type { ResolvedWaveformChartProps } from './waveformChartTypes'
|
||||||
|
import { applyXDomainStrategy } from './xDomain'
|
||||||
import type { useWaveformAnnotationInteraction } from '../annotation'
|
import type { useWaveformAnnotationInteraction } from '../annotation'
|
||||||
|
|
||||||
interface LayoutContext {
|
interface LayoutContext {
|
||||||
@@ -224,9 +225,13 @@ export function useWaveformLayout(context: LayoutContext) {
|
|||||||
Number.isFinite(domain[1]) &&
|
Number.isFinite(domain[1]) &&
|
||||||
domain[0] !== domain[1]
|
domain[0] !== domain[1]
|
||||||
) {
|
) {
|
||||||
return domain[0] < domain[1] ? domain : [domain[1], domain[0]]
|
return applyXDomainStrategy(
|
||||||
|
domain[0] < domain[1] ? domain : [domain[1], domain[0]],
|
||||||
|
props.xDomainStrategy,
|
||||||
|
true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return sharedXDomain.value
|
return applyXDomainStrategy(sharedXDomain.value, props.xDomainStrategy)
|
||||||
})
|
})
|
||||||
const resolveInitialTrackDomain = (track: TrackLayout): [number, number] => {
|
const resolveInitialTrackDomain = (track: TrackLayout): [number, number] => {
|
||||||
const configuredDomain =
|
const configuredDomain =
|
||||||
@@ -239,11 +244,18 @@ export function useWaveformLayout(context: LayoutContext) {
|
|||||||
Number.isFinite(configuredDomain[1]) &&
|
Number.isFinite(configuredDomain[1]) &&
|
||||||
configuredDomain[0] !== configuredDomain[1]
|
configuredDomain[0] !== configuredDomain[1]
|
||||||
) {
|
) {
|
||||||
return configuredDomain[0] < configuredDomain[1]
|
return applyXDomainStrategy(
|
||||||
|
configuredDomain[0] < configuredDomain[1]
|
||||||
? configuredDomain
|
? configuredDomain
|
||||||
: [configuredDomain[1], configuredDomain[0]]
|
: [configuredDomain[1], configuredDomain[0]],
|
||||||
|
props.xDomainStrategy,
|
||||||
|
true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return paddedDomain(track.seriesList.flatMap((series) => series.xDomain))
|
return applyXDomainStrategy(
|
||||||
|
paddedDomain(track.seriesList.flatMap((series) => series.xDomain)),
|
||||||
|
props.xDomainStrategy,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
const sharedZoomDomain = computed(
|
const sharedZoomDomain = computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -272,6 +284,7 @@ export function useWaveformLayout(context: LayoutContext) {
|
|||||||
sharedZoomDomain: sharedZoomDomain.value,
|
sharedZoomDomain: sharedZoomDomain.value,
|
||||||
initialXDomain: props.initialXDomain ? initialXDomain.value : undefined,
|
initialXDomain: props.initialXDomain ? initialXDomain.value : undefined,
|
||||||
initialXDomains: props.initialXDomains,
|
initialXDomains: props.initialXDomains,
|
||||||
|
xDomainStrategy: props.xDomainStrategy,
|
||||||
fixedYDomain: props.yDomain,
|
fixedYDomain: props.yDomain,
|
||||||
fixedYDomains: props.yDomains,
|
fixedYDomains: props.yDomains,
|
||||||
yDomains:
|
yDomains:
|
||||||
|
|||||||
@@ -11,8 +11,10 @@ import type {
|
|||||||
WaveformPoint,
|
WaveformPoint,
|
||||||
WaveformRenderingOptions,
|
WaveformRenderingOptions,
|
||||||
WaveformTitleOptions,
|
WaveformTitleOptions,
|
||||||
|
WaveformXDomainStrategy,
|
||||||
WaveformZeroLineOptions,
|
WaveformZeroLineOptions,
|
||||||
WaveformZoomEndPayload,
|
WaveformZoomEndPayload,
|
||||||
|
WaveformZoomResetPayload,
|
||||||
} from '../data/types'
|
} from '../data/types'
|
||||||
import type { WaveformGridOptions } from './grid'
|
import type { WaveformGridOptions } from './grid'
|
||||||
|
|
||||||
@@ -32,6 +34,7 @@ export interface WaveformChartProps {
|
|||||||
minVisiblePoints?: number
|
minVisiblePoints?: number
|
||||||
initialXDomain?: [number, number]
|
initialXDomain?: [number, number]
|
||||||
initialXDomains?: Record<string, [number, number]>
|
initialXDomains?: Record<string, [number, number]>
|
||||||
|
xDomainStrategy?: WaveformXDomainStrategy
|
||||||
yDomain?: [number, number]
|
yDomain?: [number, number]
|
||||||
yDomains?: Record<string, [number, number]>
|
yDomains?: Record<string, [number, number]>
|
||||||
timeUnit?: 's' | 'ms'
|
timeUnit?: 's' | 'ms'
|
||||||
@@ -62,6 +65,7 @@ type DefaultedProp =
|
|||||||
| 'zoomable'
|
| 'zoomable'
|
||||||
| 'pannable'
|
| 'pannable'
|
||||||
| 'minVisiblePoints'
|
| 'minVisiblePoints'
|
||||||
|
| 'xDomainStrategy'
|
||||||
| 'timeUnit'
|
| 'timeUnit'
|
||||||
| 'annotations'
|
| 'annotations'
|
||||||
| 'annotationsVisible'
|
| 'annotationsVisible'
|
||||||
@@ -82,7 +86,7 @@ export interface WaveformChartEmit {
|
|||||||
(event: 'point-hover', point: WaveformPoint | null): void
|
(event: 'point-hover', point: WaveformPoint | null): void
|
||||||
(event: 'zoom-change', domain: [number, number]): void
|
(event: 'zoom-change', domain: [number, number]): void
|
||||||
(event: 'zoom-end', payload: WaveformZoomEndPayload): void
|
(event: 'zoom-end', payload: WaveformZoomEndPayload): void
|
||||||
(event: 'zoom-reset'): void
|
(event: 'zoom-reset', payload: WaveformZoomResetPayload): void
|
||||||
(event: 'update:annotations', annotations: WaveformAnnotation[]): void
|
(event: 'update:annotations', annotations: WaveformAnnotation[]): void
|
||||||
(event: 'update:hidden-series-ids', ids: string[]): void
|
(event: 'update:hidden-series-ids', ids: string[]): void
|
||||||
(
|
(
|
||||||
|
|||||||
28
src/components/core/xDomain.test.ts
Normal file
28
src/components/core/xDomain.test.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { applyXDomainStrategy } from './xDomain'
|
||||||
|
|
||||||
|
describe('applyXDomainStrategy', () => {
|
||||||
|
it('keeps the exact data domain by default', () => {
|
||||||
|
expect(applyXDomainStrategy([0, 4999.999], { type: 'data' })).toEqual([0, 4999.999])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('expands both bounds to stable nice values', () => {
|
||||||
|
expect(applyXDomainStrategy([123, 456], { type: 'nice' })).toEqual([100, 500])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('can expand only the end bound', () => {
|
||||||
|
expect(applyXDomainStrategy([123, 456], { type: 'nice', bounds: 'end' })).toEqual([123, 500])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps explicit domains exact unless they are included', () => {
|
||||||
|
expect(applyXDomainStrategy([123, 456], { type: 'nice' }, true)).toEqual([123, 456])
|
||||||
|
expect(applyXDomainStrategy([123, 456], { type: 'nice', includeExplicit: true }, true)).toEqual(
|
||||||
|
[100, 500],
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('falls back to the default tick count for invalid values', () => {
|
||||||
|
expect(applyXDomainStrategy([0, 4999.999], { type: 'nice', tickCount: 0 })).toEqual([0, 5000])
|
||||||
|
})
|
||||||
|
})
|
||||||
25
src/components/core/xDomain.ts
Normal file
25
src/components/core/xDomain.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { scaleLinear } from 'd3'
|
||||||
|
|
||||||
|
import type { WaveformXDomainStrategy } from '../../types'
|
||||||
|
|
||||||
|
const DEFAULT_NICE_TICK_COUNT = 10
|
||||||
|
|
||||||
|
function resolveTickCount(value: number | undefined): number {
|
||||||
|
if (!Number.isFinite(value) || (value ?? 0) < 1) return DEFAULT_NICE_TICK_COUNT
|
||||||
|
return Math.max(1, Math.trunc(value as number))
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Derives a viewport domain without changing any source coordinates. */
|
||||||
|
export function applyXDomainStrategy(
|
||||||
|
domain: [number, number],
|
||||||
|
strategy: WaveformXDomainStrategy,
|
||||||
|
explicit = false,
|
||||||
|
): [number, number] {
|
||||||
|
if (strategy.type !== 'nice' || (explicit && !strategy.includeExplicit)) return [...domain]
|
||||||
|
|
||||||
|
const niceDomain = scaleLinear()
|
||||||
|
.domain(domain)
|
||||||
|
.nice(resolveTickCount(strategy.tickCount))
|
||||||
|
.domain() as [number, number]
|
||||||
|
return strategy.bounds === 'end' ? [domain[0], niceDomain[1]] : niceDomain
|
||||||
|
}
|
||||||
@@ -9,7 +9,9 @@ export type {
|
|||||||
WaveformDisplayMode,
|
WaveformDisplayMode,
|
||||||
WaveformOverlayMode,
|
WaveformOverlayMode,
|
||||||
WaveformInteractionMode,
|
WaveformInteractionMode,
|
||||||
|
WaveformXDomainStrategy,
|
||||||
WaveformZoomEndPayload,
|
WaveformZoomEndPayload,
|
||||||
|
WaveformZoomResetPayload,
|
||||||
WaveformAnnotationStyle,
|
WaveformAnnotationStyle,
|
||||||
WaveformAnnotation,
|
WaveformAnnotation,
|
||||||
WaveformRenderingOptions,
|
WaveformRenderingOptions,
|
||||||
|
|||||||
@@ -346,9 +346,23 @@ export function useWaveformViewport(context: ViewportContext) {
|
|||||||
}
|
}
|
||||||
const requestViewportReset = (event: MouseEvent) => {
|
const requestViewportReset = (event: MouseEvent) => {
|
||||||
if (isPresentationMode.value || !props.zoomable || !isZoomMode.value) return
|
if (isPresentationMode.value || !props.zoomable || !isZoomMode.value) return
|
||||||
|
if (props.displayMode === 'independent') {
|
||||||
|
const target = event.target instanceof Element ? event.target : null
|
||||||
|
const overlay = target?.closest('[data-independent-overlay-index]')
|
||||||
|
const trackIndex = Number(overlay?.getAttribute('data-independent-overlay-index'))
|
||||||
|
const track = trackLayouts.value.find((item) => item.index === trackIndex)
|
||||||
|
if (!track) return
|
||||||
|
event.preventDefault()
|
||||||
|
resetViewport(trackIndex)
|
||||||
|
emit('zoom-reset', {
|
||||||
|
trackIndex,
|
||||||
|
seriesIds: track.legendSeries.map((series) => series.id),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
resetViewport()
|
resetViewport()
|
||||||
emit('zoom-reset')
|
emit('zoom-reset', {})
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
selectionBox,
|
selectionBox,
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ export type {
|
|||||||
WaveformDisplayMode,
|
WaveformDisplayMode,
|
||||||
WaveformOverlayMode,
|
WaveformOverlayMode,
|
||||||
WaveformInteractionMode,
|
WaveformInteractionMode,
|
||||||
|
WaveformXDomainStrategy,
|
||||||
WaveformAnnotationStyle,
|
WaveformAnnotationStyle,
|
||||||
WaveformAnnotation,
|
WaveformAnnotation,
|
||||||
WaveformRenderingOptions,
|
WaveformRenderingOptions,
|
||||||
|
|||||||
@@ -308,69 +308,4 @@ describe('WaveformChart', () => {
|
|||||||
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount + 1)
|
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount + 1)
|
||||||
expect(zoomedOutDomain).toEqual([0, 2])
|
expect(zoomedOutDomain).toEqual([0, 2])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('resets a shared viewport on double-click and emits zoom-reset', async () => {
|
|
||||||
const wrapper = await mountSizedChart({
|
|
||||||
kind: 'points',
|
|
||||||
points: [
|
|
||||||
{ x: 0, y: 0 },
|
|
||||||
{ x: 2, y: 1 },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
const overlay = wrapper.get('.waveform-chart__overlay')
|
|
||||||
const width = Number(overlay.attributes('width'))
|
|
||||||
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
|
||||||
value: () => ({ left: 0, top: 0, width, height: 290 }),
|
|
||||||
})
|
|
||||||
overlay.element.dispatchEvent(
|
|
||||||
new WheelEvent('wheel', {
|
|
||||||
deltaY: -4000,
|
|
||||||
clientX: width / 2,
|
|
||||||
clientY: 145,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
flushAnimationFrames()
|
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe('0')
|
|
||||||
|
|
||||||
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')
|
|
||||||
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('exposes resetViewport for independent tracks', async () => {
|
|
||||||
const wrapper = await mountSizedChart(gridSeries(2), {
|
|
||||||
displayMode: 'independent',
|
|
||||||
grid: { rowCount: 1, columnCount: 2 },
|
|
||||||
})
|
|
||||||
const overlay = wrapper.findAll('.waveform-chart__overlay--independent')[0]
|
|
||||||
const width = Number(overlay.attributes('width'))
|
|
||||||
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
|
||||||
value: () => ({ left: 0, top: 0, width, height: 260 }),
|
|
||||||
})
|
|
||||||
overlay.element.dispatchEvent(
|
|
||||||
new WheelEvent('wheel', {
|
|
||||||
deltaY: -4000,
|
|
||||||
clientX: width / 2,
|
|
||||||
clientY: 130,
|
|
||||||
bubbles: true,
|
|
||||||
cancelable: true,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
flushAnimationFrames()
|
|
||||||
await flushPromises()
|
|
||||||
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).not.toBe('0')
|
|
||||||
|
|
||||||
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')
|
|
||||||
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1000')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
149
src/components/waveformChartCases/viewportReset.test.ts
Normal file
149
src/components/waveformChartCases/viewportReset.test.ts
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { flushAnimationFrames } from '../../test/setup'
|
||||||
|
import { gridSeries, mountSizedChart } from '../../test/waveformChart'
|
||||||
|
|
||||||
|
describe('WaveformChart viewport reset', () => {
|
||||||
|
const niceExplicitStrategy = {
|
||||||
|
type: 'nice' as const,
|
||||||
|
bounds: 'end' as const,
|
||||||
|
tickCount: 10,
|
||||||
|
includeExplicit: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
it('resets a shared viewport on double-click and emits a global payload', async () => {
|
||||||
|
const wrapper = await mountSizedChart(
|
||||||
|
{
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 2, y: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ displayMode: 'separated' },
|
||||||
|
)
|
||||||
|
const overlay = wrapper.get('.waveform-chart__overlay')
|
||||||
|
const width = Number(overlay.attributes('width'))
|
||||||
|
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||||
|
value: () => ({ left: 0, top: 0, width, height: 290 }),
|
||||||
|
})
|
||||||
|
overlay.element.dispatchEvent(
|
||||||
|
new WheelEvent('wheel', {
|
||||||
|
deltaY: -4000,
|
||||||
|
clientX: width / 2,
|
||||||
|
clientY: 145,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
flushAnimationFrames()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe('0')
|
||||||
|
|
||||||
|
overlay.element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.emitted('zoom-reset')).toEqual([[{}]])
|
||||||
|
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
|
||||||
|
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2000')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets only the double-clicked independent track and identifies it', async () => {
|
||||||
|
const wrapper = await mountSizedChart(gridSeries(2), {
|
||||||
|
displayMode: 'independent',
|
||||||
|
grid: { rowCount: 1, columnCount: 2 },
|
||||||
|
})
|
||||||
|
const overlays = wrapper.findAll('.waveform-chart__overlay--independent')
|
||||||
|
for (const overlay of overlays) {
|
||||||
|
const width = Number(overlay.attributes('width'))
|
||||||
|
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||||
|
value: () => ({ left: 0, top: 0, width, height: 260 }),
|
||||||
|
})
|
||||||
|
overlay.element.dispatchEvent(
|
||||||
|
new WheelEvent('wheel', {
|
||||||
|
deltaY: -4000,
|
||||||
|
clientX: width / 2,
|
||||||
|
clientY: 130,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
flushAnimationFrames()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
const startsBeforeReset = wrapper.findAll('.waveform-chart__axis-endpoint--start')
|
||||||
|
expect(startsBeforeReset[0].text()).not.toBe('0')
|
||||||
|
expect(startsBeforeReset[1].text()).not.toBe('0')
|
||||||
|
const secondTrackStart = startsBeforeReset[1].text()
|
||||||
|
|
||||||
|
overlays[0].element.dispatchEvent(
|
||||||
|
new MouseEvent('dblclick', { bubbles: true, cancelable: true }),
|
||||||
|
)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
const startsAfterReset = wrapper.findAll('.waveform-chart__axis-endpoint--start')
|
||||||
|
expect(startsAfterReset[0].text()).toBe('0')
|
||||||
|
expect(startsAfterReset[1].text()).toBe(secondTrackStart)
|
||||||
|
expect(wrapper.emitted('zoom-reset')).toEqual([[{ trackIndex: 0, seriesIds: ['channel-0'] }]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps the exposed no-argument resetViewport global', async () => {
|
||||||
|
const wrapper = await mountSizedChart(gridSeries(2), {
|
||||||
|
displayMode: 'independent',
|
||||||
|
grid: { rowCount: 1, columnCount: 2 },
|
||||||
|
})
|
||||||
|
const overlay = wrapper.findAll('.waveform-chart__overlay--independent')[0]
|
||||||
|
const width = Number(overlay.attributes('width'))
|
||||||
|
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||||
|
value: () => ({ left: 0, top: 0, width, height: 260 }),
|
||||||
|
})
|
||||||
|
overlay.element.dispatchEvent(
|
||||||
|
new WheelEvent('wheel', {
|
||||||
|
deltaY: -4000,
|
||||||
|
clientX: width / 2,
|
||||||
|
clientY: 130,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
flushAnimationFrames()
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).not.toBe('0')
|
||||||
|
|
||||||
|
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')
|
||||||
|
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1000')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resets shared and independent explicit domains to their included nice bounds', async () => {
|
||||||
|
const shared = await mountSizedChart(
|
||||||
|
{
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 4.999999, y: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ initialXDomain: [0, 4.999999], xDomainStrategy: niceExplicitStrategy },
|
||||||
|
)
|
||||||
|
const independent = await mountSizedChart(gridSeries(2), {
|
||||||
|
displayMode: 'independent',
|
||||||
|
initialXDomains: { 'channel-0': [0, 4.999999] },
|
||||||
|
xDomainStrategy: niceExplicitStrategy,
|
||||||
|
})
|
||||||
|
|
||||||
|
;(shared.vm as unknown as { resetViewport: () => void }).resetViewport()
|
||||||
|
;(independent.vm as unknown as { resetViewport: () => void }).resetViewport()
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(shared.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
|
||||||
|
expect(shared.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000')
|
||||||
|
const firstTrack = independent.findAll('.waveform-chart__track')[0]
|
||||||
|
expect(firstTrack.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
|
||||||
|
expect(firstTrack.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000')
|
||||||
|
})
|
||||||
|
})
|
||||||
96
src/components/waveformChartCases/xDomainStrategy.test.ts
Normal file
96
src/components/waveformChartCases/xDomainStrategy.test.ts
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { gridSeries, mountSizedChart } from '../../test/waveformChart'
|
||||||
|
|
||||||
|
describe('WaveformChart x domain strategy', () => {
|
||||||
|
it('expands automatic x domains without changing source seconds', async () => {
|
||||||
|
const data = {
|
||||||
|
kind: 'points' as const,
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 4.999999, y: 1 },
|
||||||
|
],
|
||||||
|
}
|
||||||
|
const wrapper = await mountSizedChart(data, {
|
||||||
|
timeUnit: 'ms',
|
||||||
|
xDomainStrategy: { type: 'nice', bounds: 'end' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
|
||||||
|
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000')
|
||||||
|
expect(data.points[1]?.x).toBe(4.999999)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps explicit initial x domains exact by default', async () => {
|
||||||
|
const wrapper = await mountSizedChart(
|
||||||
|
{
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 123, y: 0 },
|
||||||
|
{ x: 456, y: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
timeUnit: 's',
|
||||||
|
initialXDomain: [120, 460],
|
||||||
|
xDomainStrategy: { type: 'nice' },
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('120')
|
||||||
|
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('460')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps explicit per-track initial x domains exact by default', async () => {
|
||||||
|
const wrapper = await mountSizedChart(gridSeries(1), {
|
||||||
|
displayMode: 'independent',
|
||||||
|
initialXDomains: { 'channel-0': [0, 4.999999] },
|
||||||
|
xDomainStrategy: { type: 'nice', bounds: 'end' },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
|
||||||
|
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4999.999')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes an explicit shared initial x domain when requested', async () => {
|
||||||
|
const wrapper = await mountSizedChart(
|
||||||
|
{
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 4.999999, y: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
initialXDomain: [0, 4.999999],
|
||||||
|
xDomainStrategy: {
|
||||||
|
type: 'nice',
|
||||||
|
bounds: 'end',
|
||||||
|
tickCount: 10,
|
||||||
|
includeExplicit: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
|
||||||
|
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('includes explicit per-track initial x domains in independent mode', async () => {
|
||||||
|
const wrapper = await mountSizedChart(gridSeries(2), {
|
||||||
|
displayMode: 'independent',
|
||||||
|
grid: { rowCount: 1, columnCount: 2 },
|
||||||
|
initialXDomains: {
|
||||||
|
'channel-0': [0, 4.999999],
|
||||||
|
'channel-1': [10, 14.999999],
|
||||||
|
},
|
||||||
|
xDomainStrategy: { type: 'nice', bounds: 'end', tickCount: 10, includeExplicit: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
const tracks = wrapper.findAll('.waveform-chart__track')
|
||||||
|
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
|
||||||
|
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000')
|
||||||
|
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('10000')
|
||||||
|
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('15000')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -13,7 +13,9 @@ export type {
|
|||||||
WaveformDisplayMode,
|
WaveformDisplayMode,
|
||||||
WaveformOverlayMode,
|
WaveformOverlayMode,
|
||||||
WaveformInteractionMode,
|
WaveformInteractionMode,
|
||||||
|
WaveformXDomainStrategy,
|
||||||
WaveformZoomEndPayload,
|
WaveformZoomEndPayload,
|
||||||
|
WaveformZoomResetPayload,
|
||||||
WaveformAnnotationStyle,
|
WaveformAnnotationStyle,
|
||||||
WaveformAnnotation,
|
WaveformAnnotation,
|
||||||
WaveformRenderingOptions,
|
WaveformRenderingOptions,
|
||||||
|
|||||||
@@ -26,6 +26,17 @@ export type WaveformOverlayMode = 'single-axis' | 'multi-axis'
|
|||||||
/** 标注工具模式 */
|
/** 标注工具模式 */
|
||||||
export type WaveformInteractionMode = 'zoom' | 'annotation'
|
export type WaveformInteractionMode = 'zoom' | 'annotation'
|
||||||
|
|
||||||
|
/** Controls how the initial X viewport is derived when no explicit domain is configured. */
|
||||||
|
export interface WaveformXDomainStrategy {
|
||||||
|
type: 'data' | 'nice'
|
||||||
|
/** Selects which bounds are expanded when type is `nice`. Defaults to `both`. */
|
||||||
|
bounds?: 'both' | 'end'
|
||||||
|
/** Stable tick count used to calculate nice bounds. Defaults to 10. */
|
||||||
|
tickCount?: number
|
||||||
|
/** Applies the strategy to explicit initial domains. Defaults to false. */
|
||||||
|
includeExplicit?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
/** Describes the X-axis viewport after a zoom gesture completes. */
|
/** Describes the X-axis viewport after a zoom gesture completes. */
|
||||||
export interface WaveformZoomEndPayload {
|
export interface WaveformZoomEndPayload {
|
||||||
start: number
|
start: number
|
||||||
@@ -38,6 +49,12 @@ export interface WaveformZoomEndPayload {
|
|||||||
gesture?: 'wheel' | 'box'
|
gesture?: 'wheel' | 'box'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Identifies the viewport reset by a double-click gesture. */
|
||||||
|
export interface WaveformZoomResetPayload {
|
||||||
|
trackIndex?: number
|
||||||
|
seriesIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
/** 标注颜色样式 */
|
/** 标注颜色样式 */
|
||||||
export interface WaveformAnnotationStyle {
|
export interface WaveformAnnotationStyle {
|
||||||
borderColor?: string
|
borderColor?: string
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ export type {
|
|||||||
WaveformDisplayMode,
|
WaveformDisplayMode,
|
||||||
WaveformOverlayMode,
|
WaveformOverlayMode,
|
||||||
WaveformInteractionMode,
|
WaveformInteractionMode,
|
||||||
|
WaveformXDomainStrategy,
|
||||||
WaveformZoomEndPayload,
|
WaveformZoomEndPayload,
|
||||||
|
WaveformZoomResetPayload,
|
||||||
WaveformAnnotationStyle,
|
WaveformAnnotationStyle,
|
||||||
WaveformAnnotation,
|
WaveformAnnotation,
|
||||||
WaveformRenderingOptions,
|
WaveformRenderingOptions,
|
||||||
|
|||||||
Reference in New Issue
Block a user