14 Commits

Author SHA1 Message Date
李启源
7a6a7e5c26 feat(data): 扩展示例波形数据与测试覆盖
All checks were successful
Package component / package (push) Successful in 4m26s
新增示例谐波对比数据,完善图例、多轴和演示测试,并同步文档说明。
2026-08-17 14:34:15 +08:00
李启源
dda87f7508 feat(annotation): 优化标注编辑与交互体验
All checks were successful
Package component / package (push) Successful in 4m23s
标注编辑器支持时间输入与采样点吸附,完善标注交互、默认配色和示例图框样式。
2026-08-17 12:16:12 +08:00
李启源
3692e21dbc feat(chart): 增加炮号并优化 Tooltip 展示
All checks were successful
Package component / package (push) Successful in 4m5s
新增 WaveformSeries.shotNo 字段,Tooltip 展示炮号及坐标信息,并同步示例与测试。
2026-08-17 10:14:44 +08:00
李启源
54477ecbbd fix(ci): checkout release archive without git pack
All checks were successful
Package component / package (push) Successful in 6m22s
2026-08-13 22:03:57 +08:00
李启源
d349220395 fix(ci): avoid shallow tag pack checkout
Some checks failed
Package component / package (push) Failing after 2s
2026-08-13 21:56:58 +08:00
李启源
81f36bde71 feat(chart): support explicit nice x domains
Some checks failed
Package component / package (push) Failing after 3s
2026-08-13 21:51:49 +08:00
李启源
e940e0af1d chore(release): prepare v0.1.32
All checks were successful
Package component / package (push) Successful in 5m58s
2026-08-11 15:29:41 +08:00
李启源
3d6c5156c0 fix(chart): scope independent viewport resets 2026-08-11 15:29:32 +08:00
李启源
d667350219 docs: update repository guidelines 2026-08-10 16:56:21 +08:00
李启源
d79185b952 chore(release): prepare v0.1.31
All checks were successful
Package component / package (push) Successful in 4m41s
2026-08-10 16:55:19 +08:00
李启源
4dd53aae11 fix(chart): honor minimum visible zoom points 2026-08-10 15:54:32 +08:00
liqiyuan
9b42e3797b feat(chart): refine viewport interaction and rendering 2026-08-08 10:43:39 +08:00
李启源
7646bf4907 merge: integrate hybrid domain architecture 2026-08-07 15:59:38 +08:00
李启源
e2725e5569 refactor(chart): introduce hybrid domain architecture 2026-08-07 15:59:26 +08:00
71 changed files with 2933 additions and 665 deletions

View File

@@ -15,12 +15,13 @@ jobs:
run: | run: |
set -euo pipefail set -euo pipefail
server_url="${{ gitea.server_url }}" server_url="${{ gitea.server_url }}"
repository_url="${server_url%/}/${{ gitea.repository }}.git"
tag_name="${{ gitea.ref_name }}" tag_name="${{ gitea.ref_name }}"
git init . archive_url="${server_url%/}/api/v1/repos/${{ gitea.repository }}/archive/$tag_name.tar.gz"
git remote add origin "$repository_url" curl --fail --show-error --silent --location "$archive_url" --output source.tar.gz
git fetch --depth 1 origin "refs/tags/$tag_name:refs/tags/$tag_name" mkdir source
git checkout --detach "$tag_name^{commit}" tar -xzf source.tar.gz --strip-components=1 -C source
cp -a source/. .
rm -rf source source.tar.gz
- name: Set up pnpm - name: Set up pnpm
run: | run: |
@@ -214,7 +215,7 @@ jobs:
: "${RELEASE_TOKEN:?RELEASE_TOKEN is required}" : "${RELEASE_TOKEN:?RELEASE_TOKEN is required}"
server_url="${{ gitea.server_url }}" server_url="${{ gitea.server_url }}"
api_url="$server_url/api/v1/repos/${{ gitea.repository }}/releases" api_url="$server_url/api/v1/repos/${{ gitea.repository }}/releases"
target_commitish="$(git rev-parse HEAD)" target_commitish="$TAG_NAME"
node - "$TAG_NAME" "$PACKAGE_VERSION" "$target_commitish" "$IS_PRERELEASE" > release.json <<'NODE' node - "$TAG_NAME" "$PACKAGE_VERSION" "$target_commitish" "$IS_PRERELEASE" > release.json <<'NODE'
const [tagName, packageVersion, targetCommitish, prerelease] = process.argv.slice(2) const [tagName, packageVersion, targetCommitish, prerelease] = process.argv.slice(2)

View File

@@ -19,6 +19,7 @@ jobs:
- run: pnpm install --frozen-lockfile - run: pnpm install --frozen-lockfile
- run: pnpm typecheck - run: pnpm typecheck
- run: pnpm check:file-length - run: pnpm check:file-length
- run: pnpm lint:oxlint
- run: pnpm lint - run: pnpm lint
- run: pnpm test:coverage - run: pnpm test:coverage
- run: pnpm build - run: pnpm build

8
.oxlintrc.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"env": {
"browser": true,
"node": true
},
"ignorePatterns": ["dist/**", "dist-demo/**", "coverage/**", "node_modules/**"]
}

153
AGENTS.md
View File

@@ -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 lint # Run ESLint with zero warnings allowed pnpm check:file-length # Enforce the 400-line limit under src/
pnpm test # Run Vitest once pnpm lint # Run ESLint with zero warnings allowed
pnpm test:coverage # Run tests and enforce coverage thresholds pnpm test # Run Vitest once
pnpm build # Type-check and build library plus demo bundles pnpm test:coverage # Run tests and enforce coverage thresholds
pnpm preview # Preview the production demo build pnpm build # Type-check and build library, declarations, and demo
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.

110
README.md
View File

@@ -96,40 +96,41 @@ const data = ref<WaveformData>({
### Props ### Props
| Prop | 类型 | 默认值 | 说明 | | Prop | 类型 | 默认值 | 说明 |
| --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | ---------------------------------- | | --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | --------------------------------------------- |
| `data` | `WaveformData` | 必填 | 波形数据 | | `data` | `WaveformData` | 必填 | 波形数据 |
| `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 | | `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 |
| `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 | | `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 |
| `timeUnit` | `'s' \| 'ms'` | `'ms'` | 坐标轴和 tooltip 展示单位 | | `timeUnit` | `'s' \| 'ms'` | `'ms'` | 坐标轴和 tooltip 展示单位 |
| `xLabel` / `yLabel` | `string` | `时间timeUnit` / `'幅值'` | 坐标轴名称 | | `xLabel` / `yLabel` | `string` | `时间timeUnit` / `'幅值'` | 坐标轴名称 |
| `lineColor` | `string` | `'#0960bd'` | 单波形默认颜色 | | `lineColor` | `string` | `'#0960bd'` | 单波形默认颜色 |
| `width` / `height` | `number` | 自适应 | 组件总尺寸,单位为 CSS 像素 | | `width` / `height` | `number` | 自适应 | 组件总尺寸,单位为 CSS 像素 |
| `zoomable` / `showTooltip` | `boolean` | `true` / `true` | 缩放和数值 tooltip 开关 | | `zoomable` / `showTooltip` | `boolean` | `true` / `true` | 缩放和数值 tooltip 开关 |
| `pannable` | `boolean` | `false` | 空格拖拽平移开关 | | `pannable` | `boolean` | `false` | 空格拖拽平移开关 |
| `minZoomSpan` | `number` | 未设置 | 最小缩放跨度,使用原始 X 数据单位 | | `minZoomSpan` | `number` | 未设置 | 最小缩放跨度,使用原始 X 数据单位 |
| `minVisiblePoints` | `number` | `0` | 缩放后至少保留的不同 X 坐标数 | | `minVisiblePoints` | `number` | `0` | 缩放后至少保留的不同 X 坐标数 |
| `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围 | | `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围(可超出数据,空白显示) |
| `initialXDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置初始范围 | | `initialXDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置初始范围(可超出数据) |
| `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 | | `xDomainStrategy` | `WaveformXDomainStrategy` | `{ type: 'data' }` | 自动 X 轴视口范围策略 |
| `yDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置固定范围 | | `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 |
| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 | | `yDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置固定范围 |
| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐与 X 轴 label 格式化 | | `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 |
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 | | `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐与 X 轴 label 格式化 |
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 | | `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
| `frameNumber` | `string \| number` | 未设置 | 图框水印内容 | | `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
| `zeroLine` | `WaveformZeroLineOptions` | `{ visible: false }` | 零值参考线显隐与样式 | | `frameNumber` | `string \| number` | 未设置 | 图框水印内容 |
| `cleanView` | `boolean` | `false` | 保留波形、图框和刻度的净图模式 | | `zeroLine` | `WaveformZeroLineOptions` | `{ visible: false }` | 零值参考线显隐与样式 |
| `presentationMode` | `boolean` | `false` | 禁用绘图区交互的展示模式 | | `cleanView` | `boolean` | `false` | 保留波形、图框和刻度的净图模式 |
| `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 | | `presentationMode` | `boolean` | `false` | 禁用绘图区交互的展示模式 |
| `annotationsVisible` | `boolean` | `true` | 标注图层显隐 | | `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 |
| `interactionMode` | `'zoom' \| 'annotation'` | `'zoom'` | 左键交互模式 | | `annotationsVisible` | `boolean` | `true` | 标注图层显隐 |
| `hiddenSeriesIds` | `string[]` | 未设置 | 受控隐藏系列 ID | | `interactionMode` | `'zoom' \| 'annotation'` | `'zoom'` | 左键交互模式 |
| `defaultHiddenSeriesIds` | `string[]` | `[]` | 受控模式的初始隐藏系列 | | `hiddenSeriesIds` | `string[]` | 未设置 | 受控隐藏系列 ID |
| `defaultHiddenSeriesIds` | `string[]` | `[]` | 非受控模式的初始隐藏系列 |
所有公开类型均可从包入口导入,例如 `WaveformData``WaveformSeries` 所有公开类型均可从包入口导入,例如 `WaveformData``WaveformSeries`
`WaveformAnnotation``WaveformLineStyle``WaveformRenderingOptions` `WaveformAnnotation``WaveformLineStyle``WaveformRenderingOptions`
`WaveformAxesOptions``WaveformXAxisLabelFormatter``WaveformZeroLineOptions` `WaveformAxesOptions``WaveformXAxisLabelFormatter``WaveformXDomainStrategy``WaveformZeroLineOptions`
`WaveformGridOptions``WaveformGridTrackLines` `WaveformGridOptions``WaveformGridTrackLines`
### 数据结构 ### 数据结构
@@ -162,8 +163,8 @@ const points: WaveformData = {
const chartData: WaveformData = { const chartData: WaveformData = {
kind: 'series', kind: 'series',
series: [ series: [
{ id: 'ch-a', name: '通道 A', trackId: 'group-1', data: samples }, { id: 'ch-a', shotNo: '13300', name: '通道 A', trackId: 'group-1', data: samples },
{ id: 'ch-b', name: '通道 B', trackId: 'group-1', data: points }, { id: 'ch-b', shotNo: '13300', name: '通道 B', trackId: 'group-1', data: points },
], ],
} }
``` ```
@@ -246,18 +247,43 @@ X 轴且包含多个轨道时使用按稳定 track ID 索引的 `yRanges`。平
调用方应处理加载失败的情况(网络错误、超时等),并保持旧数据或显示加载状态。生产环境建议使用 调用方应处理加载失败的情况(网络错误、超时等),并保持旧数据或显示加载状态。生产环境建议使用
`AbortController` 取消过时的请求。 `AbortController` 取消过时的请求。
`initialXDomain` 固定首次完整数据的 X 轴缩放边界,不要将它改成后端返回的当前窗口;独立图框有不同时间范围时,可通过 `initialXDomain` 固定首次及重置时的 X 轴视口范围,不要将它改成后端返回的当前窗口;范围可以超出数据的实际时间,超出部分保留空白。独立图框有不同时间范围时,可通过
`initialXDomains` 按 track ID 或 series ID 分别配置。`minZoomSpan` 使用原始 X 数据单位, `initialXDomains` 按 track ID 或 series ID 分别配置。`minZoomSpan` 使用原始 X 数据单位,
可防止每次区间数据回填后重新累计放大。双击图框会 可防止每次区间数据回填后重新累计放大。双击图框会
重置组件内部缩放并触发 `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 的显示单位。
Tooltip 每个系列按 `炮号:通道 (x:值 y:值)` 格式显示。`WaveformSeries.shotNo` 为空或未提供时,
炮号显示为“未配置炮号”Tooltip 不显示单位和误差附加文本。
### 线型、点型与误差棒 ### 线型、点型与误差棒
每条序列可以独立设置连线方式、数据点符号和误差棒: 每条序列可以独立设置连线方式、数据点符号和误差棒:
@@ -265,6 +291,7 @@ X 轴且包含多个轨道时使用按稳定 track ID 索引的 `yRanges`。平
```ts ```ts
const series = { const series = {
id: 'temperature', id: 'temperature',
shotNo: '13300',
name: '温度', name: '温度',
lineType: 'step-end', lineType: 'step-end',
lineStyle: 'dashed', lineStyle: 'dashed',
@@ -388,7 +415,8 @@ Demo 左侧控制面板提供标题实时预览,可配置标题名称、显隐
### 图例与曲线显隐 ### 图例与曲线显隐
`legend.backgroundColor` 设置多曲线图例的背景颜色。该字段接受任意有效 CSS 颜色值, 每个图框独立管理自己的图例:图框内有两条或更多曲线时显示图例,只有一条曲线时不显示。
`legend.backgroundColor` 设置图例的背景颜色。该字段接受任意有效 CSS 颜色值,
可通过 `rgba(...)``hsla(...)` 中的 alpha 通道调整透明度: 可通过 `rgba(...)``hsla(...)` 中的 alpha 通道调整透明度:
```vue ```vue
@@ -639,7 +667,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` | 标注新增、更新或删除 |
@@ -668,12 +696,20 @@ pnpm dev
```bash ```bash
pnpm typecheck pnpm typecheck
pnpm lint:oxlint
pnpm lint pnpm lint
pnpm lint:all
pnpm format:check
pnpm test pnpm test
pnpm test:coverage pnpm test:coverage
pnpm build 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/` 演示应用。正式公开入口为 `pnpm build` 同时生成 `dist/` 组件库产物和 `dist-demo/` 演示应用。正式公开入口为
`src/index.ts`,样式入口为 `src/styles.css``dist/``dist-demo/` 均为生成目录,不要手工编辑。 `src/index.ts`,样式入口为 `src/styles.css``dist/``dist-demo/` 均为生成目录,不要手工编辑。

37
docs/architecture.md Normal file
View 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.

View File

@@ -1,6 +1,6 @@
{ {
"name": "waveform-analysis", "name": "waveform-analysis",
"version": "0.1.30", "version": "0.1.38",
"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",
@@ -31,7 +31,10 @@
"typecheck": "vue-tsc -b", "typecheck": "vue-tsc -b",
"check:file-length": "node scripts/check-file-length.mjs", "check:file-length": "node scripts/check-file-length.mjs",
"lint": "eslint . --max-warnings=0", "lint": "eslint . --max-warnings=0",
"lint:oxlint": "oxlint . --deny-warnings",
"lint:all": "pnpm lint:oxlint && pnpm lint",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run", "test": "vitest run",
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage"
}, },
@@ -57,6 +60,7 @@
"eslint-plugin-vue": "10.9.2", "eslint-plugin-vue": "10.9.2",
"globals": "17.7.0", "globals": "17.7.0",
"jsdom": "29.1.1", "jsdom": "29.1.1",
"oxlint": "1.77.0",
"prettier": "3.9.5", "prettier": "3.9.5",
"typescript": "~6.0.0", "typescript": "~6.0.0",
"typescript-eslint": "8.64.0", "typescript-eslint": "8.64.0",

217
pnpm-lock.yaml generated
View File

@@ -54,6 +54,9 @@ importers:
jsdom: jsdom:
specifier: 29.1.1 specifier: 29.1.1
version: 29.1.1 version: 29.1.1
oxlint:
specifier: 1.77.0
version: 1.77.0
prettier: prettier:
specifier: 3.9.5 specifier: 3.9.5
version: 3.9.5 version: 3.9.5
@@ -279,6 +282,128 @@ packages:
'@oxc-project/types@0.139.0': '@oxc-project/types@0.139.0':
resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==} 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': '@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'} engines: {node: '>=14'}
@@ -1385,6 +1510,19 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'} 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: p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'} engines: {node: '>=10'}
@@ -1989,6 +2127,63 @@ snapshots:
'@oxc-project/types@0.139.0': {} '@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': '@pkgjs/parseargs@0.11.0':
optional: true optional: true
@@ -3143,6 +3338,28 @@ snapshots:
type-check: 0.4.0 type-check: 0.4.0
word-wrap: 1.2.5 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: p-limit@3.1.0:
dependencies: dependencies:
yocto-queue: 0.1.0 yocto-queue: 0.1.0

View File

@@ -235,7 +235,7 @@ describe('App workspace layout', { timeout: 20_000 }, () => {
tracks.map((track) => tracks.map((track) =>
track.findAll('.waveform-chart__series').map((item) => item.attributes('data-series-name')), track.findAll('.waveform-chart__series').map((item) => item.attributes('data-series-name')),
), ),
).toEqual([['正弦基波'], ['谐波扰动'], ['阻尼振荡'], ['阶跃响应']]) ).toEqual([['正弦基波'], ['谐波扰动', '谐波对比'], ['阻尼振荡'], ['阶跃响应']])
expect( expect(
tracks tracks
.slice(1) .slice(1)
@@ -253,6 +253,7 @@ describe('App workspace layout', { timeout: 20_000 }, () => {
expect(simulatedSeries.map((item) => item.name)).toEqual([ expect(simulatedSeries.map((item) => item.name)).toEqual([
'正弦基波', '正弦基波',
'谐波扰动', '谐波扰动',
'谐波对比',
'阻尼振荡', '阻尼振荡',
'阶跃响应', '阶跃响应',
'脉冲响应', '脉冲响应',
@@ -321,8 +322,8 @@ describe('App workspace layout', { timeout: 20_000 }, () => {
const styleSelect = frameControls.findAllComponents(Select)[0] const styleSelect = frameControls.findAllComponents(Select)[0]
expect(colorPickers).toHaveLength(2) expect(colorPickers).toHaveLength(2)
expect(widthInput).toBeDefined() const initialFrames = wrapper.findAll('.waveform-chart__plot-frame')
expect(styleSelect).toBeDefined() expect(initialFrames.length > 1 && initialFrames.every((frame) => frame.attributes('stroke-width') === '2')).toBe(true)
colorPickers[0].vm.$emit('update:pureColor', 'rgba(220, 38, 38, 0.8)') colorPickers[0].vm.$emit('update:pureColor', 'rgba(220, 38, 38, 0.8)')
colorPickers[1].vm.$emit('update:pureColor', 'rgba(14, 165, 233, 0.25)') colorPickers[1].vm.$emit('update:pureColor', 'rgba(14, 165, 233, 0.25)')

View File

@@ -32,7 +32,7 @@ const overlayMode = ref<WaveformOverlayMode>('single-axis')
const rowCount = ref(4) const rowCount = ref(4)
const columnCount = ref(1) const columnCount = ref(1)
const frameBorderColor = ref('#1f2937') const frameBorderColor = ref('#1f2937')
const frameBorderWidth = ref(1) const frameBorderWidth = ref(2)
const frameBorderStyle = ref<NonNullable<WaveformFrameStyle['borderStyle']>>('solid') const frameBorderStyle = ref<NonNullable<WaveformFrameStyle['borderStyle']>>('solid')
const frameBackgroundColor = ref('rgba(255, 255, 255, 0)') const frameBackgroundColor = ref('rgba(255, 255, 255, 0)')
const frameWatermarkVisible = ref(true) const frameWatermarkVisible = ref(true)
@@ -141,9 +141,6 @@ const [initialXMinimum, initialXMaximum] = initialXValues.reduce<[number, number
([minimum, maximum], value) => [Math.min(minimum, value), Math.max(maximum, value)], ([minimum, maximum], value) => [Math.min(minimum, value), Math.max(maximum, value)],
[Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY], [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 = const initialXDomainValue: [number, number] | undefined =
Number.isFinite(initialXMinimum) && Number.isFinite(initialXMaximum) Number.isFinite(initialXMinimum) && Number.isFinite(initialXMaximum)
? [initialXMinimum, initialXMaximum] ? [initialXMinimum, initialXMaximum]
@@ -353,7 +350,6 @@ const controlPanelModel = reactive({
const chartModel = reactive({ const chartModel = reactive({
data: displayChartData, data: displayChartData,
minZoomSpan,
initialXDomain, initialXDomain,
displayMode, displayMode,
overlayMode, overlayMode,

View File

@@ -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: () => [],

View File

@@ -87,9 +87,11 @@ const {
contextMenu, contextMenu,
editorSeries, editorSeries,
editorSeriesOptions, editorSeriesOptions,
timeError,
confirmAnnotation, confirmAnnotation,
cancelAnnotation, cancelAnnotation,
changeDraftSeries, changeDraftSeries,
changeDraftTime,
editContextAnnotation, editContextAnnotation,
deleteContextAnnotation, deleteContextAnnotation,
chartHeight, chartHeight,
@@ -98,6 +100,11 @@ const {
setTitleMeasureElement, setTitleMeasureElement,
setSharedOverlayElement, setSharedOverlayElement,
} = toRefs(props.controller) } = toRefs(props.controller)
function handleChartPointerLeave() {
pointerInsideChart.value = false
handlePointerLeave.value()
}
</script> </script>
<template> <template>
@@ -110,7 +117,7 @@ const {
{ {
'waveform-chart--clean': isCleanView, 'waveform-chart--clean': isCleanView,
'waveform-chart--presentation': isPresentationMode, 'waveform-chart--presentation': isPresentationMode,
'waveform-chart--panning': selection?.mode === 'pan', 'waveform-chart--panning': selection?.kind === 'pan',
}, },
]" ]"
:style="containerStyle" :style="containerStyle"
@@ -123,7 +130,7 @@ const {
:data-plot-margin-bottom="resolvedPlotMargin.bottom" :data-plot-margin-bottom="resolvedPlotMargin.bottom"
:data-title-area-height="titleAreaHeight" :data-title-area-height="titleAreaHeight"
@pointerenter="pointerInsideChart = true" @pointerenter="pointerInsideChart = true"
@pointerleave="pointerInsideChart = false" @pointerleave="handleChartPointerLeave"
@contextmenu.capture="handleNativeContextMenu" @contextmenu.capture="handleNativeContextMenu"
> >
<div <div
@@ -247,7 +254,7 @@ const {
/> />
<rect <rect
v-if="selectionBox && selection?.mode === 'box'" v-if="selectionBox && selection?.kind === 'box'"
class="waveform-chart__zoom-selection" class="waveform-chart__zoom-selection"
:x="selectionBox.x" :x="selectionBox.x"
:y="selectionBox.y" :y="selectionBox.y"
@@ -332,9 +339,11 @@ const {
:series="editorSeries" :series="editorSeries"
:series-options="editorSeriesOptions" :series-options="editorSeriesOptions"
:time-unit="timeUnit" :time-unit="timeUnit"
:time-error="timeError"
@confirm="confirmAnnotation" @confirm="confirmAnnotation"
@cancel="cancelAnnotation" @cancel="cancelAnnotation"
@series-change="changeDraftSeries" @series-change="changeDraftSeries"
@time-change="changeDraftTime"
/> />
<WaveformAnnotationContextMenu <WaveformAnnotationContextMenu

View File

@@ -1,63 +1,8 @@
.waveform-annotation-editor { .waveform-annotation-editor__content {
position: absolute;
inset: 0;
z-index: 40;
display: grid;
place-items: center;
padding: 20px;
background: rgb(16 24 40 / 32%);
animation: waveform-annotation-editor-fade-in 160ms ease-out;
}
.waveform-annotation-editor__panel {
display: grid; display: grid;
gap: 20px; gap: 20px;
width: min(440px, 100%);
max-height: 100%;
overflow-y: auto;
padding: 22px;
color: #344054; color: #344054;
font-size: 13px; font-size: 13px;
background: #fff;
border: 1px solid #e4e7ec;
border-radius: 12px;
box-shadow: 0 20px 50px rgb(16 24 40 / 22%);
animation: waveform-annotation-editor-rise-in 180ms ease-out;
}
.waveform-annotation-editor__header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.waveform-annotation-editor__header h2 {
margin: 0;
color: #101828;
font-size: 18px;
line-height: 1.3;
}
.waveform-annotation-editor__header p {
margin: 5px 0 0;
color: #667085;
font-size: 12px;
}
.waveform-annotation-editor__close {
display: inline-grid;
flex: 0 0 30px;
width: 30px;
height: 30px;
padding: 0;
place-items: center;
color: #667085;
font-size: 22px;
line-height: 1;
background: transparent;
border: 0;
border-radius: 6px;
cursor: pointer;
}
.waveform-annotation-editor__close:hover {
color: #101828;
background: #f2f4f7;
} }
.waveform-annotation-editor__coordinates { .waveform-annotation-editor__coordinates {
display: grid; display: grid;
@@ -78,6 +23,39 @@
.waveform-annotation-editor__coordinates span { .waveform-annotation-editor__coordinates span {
color: #667085; color: #667085;
} }
.waveform-annotation-editor__coordinate-input {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
padding: 9px 10px;
color: #667085;
background: #f8fafc;
border: 1px solid #eaecf0;
border-radius: 7px;
}
.waveform-annotation-editor__coordinate-input :deep(.ant-input-number) {
width: auto;
min-width: 96px;
color: #344054;
font-family: 'SFMono-Regular', Consolas, monospace;
}
.waveform-annotation-editor__coordinate-value {
display: none;
}
.waveform-annotation-editor__coordinate-error {
grid-column: 1 / -1;
margin: -12px 0 0;
color: #d92d20;
font-size: 11px;
line-height: 1.4;
}
.waveform-annotation-editor__coordinate-hint {
margin: -12px 0 0;
color: #98a2b3;
font-size: 11px;
line-height: 1.4;
}
.waveform-annotation-editor__coordinates b { .waveform-annotation-editor__coordinates b {
color: #1677ff; color: #1677ff;
font-size: 11px; font-size: 11px;
@@ -205,56 +183,9 @@
border-color: #98a2b3; border-color: #98a2b3;
box-shadow: 0 0 0 3px rgb(22 119 255 / 12%); box-shadow: 0 0 0 3px rgb(22 119 255 / 12%);
} }
.waveform-annotation-editor__actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 8px;
border-top: 1px solid #eaecf0;
}
.waveform-annotation-editor__actions button {
height: 34px;
padding: 0 12px;
color: #475467;
background: #fff;
border: 1px solid #d0d5dd;
border-radius: 6px;
cursor: pointer;
}
.waveform-annotation-editor__actions button.is-primary {
color: #fff;
background: #1677ff;
border-color: #1677ff;
}
.waveform-annotation-editor__actions button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@keyframes waveform-annotation-editor-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@keyframes waveform-annotation-editor-rise-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.98);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@media (max-width: 420px) { @media (max-width: 420px) {
.waveform-annotation-editor { .waveform-annotation-editor__content {
padding: 12px;
}
.waveform-annotation-editor__panel {
gap: 16px; gap: 16px;
padding: 18px;
} }
.waveform-annotation-editor__coordinates { .waveform-annotation-editor__coordinates {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue' import { computed, nextTick, ref, watch } from 'vue'
import { InputNumber, Modal } from 'ant-design-vue'
import { ColorPicker } from 'vue3-colorpicker' import { ColorPicker } from 'vue3-colorpicker'
import 'vue3-colorpicker/style.css' import 'vue3-colorpicker/style.css'
@@ -15,24 +16,44 @@ interface Props {
series?: AnnotationSeriesInfo series?: AnnotationSeriesInfo
seriesOptions?: AnnotationSeriesCandidate[] seriesOptions?: AnnotationSeriesCandidate[]
timeUnit?: TimeUnit timeUnit?: TimeUnit
timeError?: string
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
timeUnit: 'ms', timeUnit: 'ms',
timeError: '',
}) })
const emit = defineEmits<{ const emit = defineEmits<{
(event: 'confirm', annotation: WaveformAnnotation): void (event: 'confirm', annotation: WaveformAnnotation): void
(event: 'cancel'): void (event: 'cancel'): void
(event: 'series-change', seriesId: string): void (event: 'series-change', seriesId: string): void
(event: 'time-change', displayValue: string): void
}>() }>()
const textarea = ref<HTMLTextAreaElement>() const textarea = ref<HTMLTextAreaElement>()
const dialogTitleId = useWaveformInstanceId('waveform-annotation-editor-title') const dialogTitleId = useWaveformInstanceId('waveform-annotation-editor-title')
const text = ref('') const text = ref('')
const timeInput = ref('')
const timeValidationRequested = ref(false)
const borderColor = ref('') const borderColor = ref('')
const textColor = ref('') const textColor = ref('')
const backgroundColor = ref('') const backgroundColor = ref('')
const canConfirm = computed(() => text.value.trim().length > 0) const inputTimeError = computed(() => {
if (!timeValidationRequested.value) return ''
if (!timeInput.value.trim() || !Number.isFinite(Number(timeInput.value)))
return '请输入有效的时间'
return ''
})
const timeErrorMessage = computed(() =>
timeValidationRequested.value ? inputTimeError.value || props.timeError || '' : '',
)
const canConfirm = computed(
() =>
text.value.trim().length > 0 &&
timeInput.value.trim().length > 0 &&
Number.isFinite(Number(timeInput.value)) &&
!timeErrorMessage.value,
)
const characterCount = computed(() => text.value.length) const characterCount = computed(() => text.value.length)
const selectedSeries = computed(() => { const selectedSeries = computed(() => {
const option = props.seriesOptions?.find( const option = props.seriesOptions?.find(
@@ -46,15 +67,30 @@ const selectedSeries = computed(() => {
function hydrate() { function hydrate() {
const style = resolveAnnotationStyle(props.annotation.style) const style = resolveAnnotationStyle(props.annotation.style)
text.value = props.annotation.text text.value = props.annotation.text
timeInput.value = formatAnnotationTime(props.annotation.x, props.timeUnit)
timeValidationRequested.value = false
borderColor.value = style.borderColor borderColor.value = style.borderColor
textColor.value = style.textColor textColor.value = style.textColor
backgroundColor.value = style.backgroundColor backgroundColor.value = style.backgroundColor
void nextTick(() => textarea.value?.focus()) void nextTick(() => textarea.value?.focus())
} }
watch(() => props.annotation, hydrate, { immediate: true }) watch(() => props.annotation.id, hydrate, { immediate: true })
function confirm() { function handleTimeInput(value: number | string | null) {
const nextValue = value === null || value === undefined ? '' : String(value)
timeInput.value = nextValue
timeValidationRequested.value = false
}
function commitTimeInput() {
timeValidationRequested.value = true
emit('time-change', timeInput.value)
}
async function confirm() {
commitTimeInput()
await nextTick()
if (!canConfirm.value) return if (!canConfirm.value) return
emit('confirm', { emit('confirm', {
...props.annotation, ...props.annotation,
@@ -82,40 +118,60 @@ function handleSeriesChange(event: Event) {
</script> </script>
<template> <template>
<div <Modal
class="waveform-annotation-editor" :visible="true"
role="dialog" :width="440"
aria-modal="true" :mask-closable="true"
:aria-labelledby="dialogTitleId" :keyboard="true"
@click.self="emit('cancel')" cancel-text="取消"
ok-text="保存标注"
:ok-button-props="{ disabled: !canConfirm }"
wrap-class-name="waveform-annotation-editor"
@cancel="emit('cancel')"
@ok="confirm"
> >
<section class="waveform-annotation-editor__panel"> <template #title>
<header class="waveform-annotation-editor__header"> <span :id="dialogTitleId">{{ props.mode === 'add' ? '添加标注' : '编辑标注' }}</span>
<div> </template>
<h2 :id="dialogTitleId">
{{ props.mode === 'add' ? '添加标注' : '编辑标注' }}
</h2>
</div>
<button
type="button"
class="waveform-annotation-editor__close"
aria-label="关闭标注编辑器"
title="关闭"
@click="emit('cancel')"
>
×
</button>
</header>
<div class="waveform-annotation-editor__content">
<div class="waveform-annotation-editor__coordinates" aria-label="标注坐标"> <div class="waveform-annotation-editor__coordinates" aria-label="标注坐标">
<span <label
><b>X ({{ props.timeUnit }})</b class="waveform-annotation-editor__coordinate-input"
><code>{{ formatAnnotationTime(props.annotation.x, props.timeUnit) }}</code></span :aria-invalid="Boolean(timeErrorMessage)"
> >
<b>X</b>
<InputNumber
:value="timeInput === '' ? undefined : Number(timeInput)"
:controls="true"
:step="0.001"
:keyboard="true"
:status="timeErrorMessage ? 'error' : undefined"
aria-label="标注横轴时间"
:aria-invalid="Boolean(timeErrorMessage)"
:aria-describedby="timeErrorMessage ? `${dialogTitleId}-time-error` : undefined"
@blur="commitTimeInput"
@update:value="handleTimeInput"
/>
<code class="waveform-annotation-editor__coordinate-value" aria-hidden="true">{{
timeInput
}}</code>
</label>
<p
v-if="timeErrorMessage"
:id="`${dialogTitleId}-time-error`"
class="waveform-annotation-editor__coordinate-error"
role="alert"
>
{{ timeErrorMessage }}
</p>
<span <span
><b>Y</b><code>{{ formatPlainNumber(props.annotation.y) }}</code></span ><b>Y</b><code>{{ formatPlainNumber(props.annotation.y) }}</code></span
> >
</div> </div>
<p class="waveform-annotation-editor__coordinate-hint" role="note">
修改 X 轴后失焦时会自动吸附最近的采样点
</p>
<label <label
v-if="selectedSeries" v-if="selectedSeries"
@@ -200,14 +256,8 @@ function handleSeriesChange(event: Event) {
</label> </label>
</fieldset> </fieldset>
<footer class="waveform-annotation-editor__actions"> </div>
<button type="button" @click="emit('cancel')">取消</button> </Modal>
<button type="button" class="is-primary" :disabled="!canConfirm" @click="confirm">
保存标注
</button>
</footer>
</section>
</div>
</template> </template>
<style scoped src="./WaveformAnnotationEditor.css"></style> <style scoped src="./WaveformAnnotationEditor.css"></style>

View File

@@ -1,28 +1,93 @@
import { flushPromises, mount } from '@vue/test-utils' import { flushPromises, mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest' import { InputNumber } from 'ant-design-vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ColorPicker } from 'vue3-colorpicker' import { ColorPicker } from 'vue3-colorpicker'
import { defineComponent, h } from 'vue'
import WaveformAnnotationContextMenu from './WaveformAnnotationContextMenu.vue' import WaveformAnnotationContextMenu from './WaveformAnnotationContextMenu.vue'
import WaveformAnnotationEditor from './WaveformAnnotationEditor.vue' import WaveformAnnotationEditor from './WaveformAnnotationEditor.vue'
import WaveformAnnotationLayer from './WaveformAnnotationLayer.vue' import WaveformAnnotationLayer from './WaveformAnnotationLayer.vue'
const modalStub = defineComponent({
props: [
'visible',
'width',
'maskClosable',
'keyboard',
'wrapClassName',
'cancelText',
'okText',
'okButtonProps',
],
emits: ['cancel', 'ok'],
setup(props, { emit, slots }) {
return () => {
const title = slots.title?.() ?? []
const titleId = (title[0]?.props as { id?: string } | undefined)?.id
return h('div', { class: 'ant-modal-root' }, [
h(
'div',
{
class: ['ant-modal-wrap', props.wrapClassName],
role: 'dialog',
'aria-modal': 'true',
'aria-labelledby': titleId,
onClick: (event: MouseEvent) => {
if (event.target === event.currentTarget && props.maskClosable) emit('cancel')
},
},
[
h('div', { class: 'ant-modal' }, [
h('div', { class: 'ant-modal-header' }, [
h('div', { class: 'ant-modal-title' }, title),
]),
h('button', { class: 'ant-modal-close', onClick: () => emit('cancel') }, '×'),
slots.default?.(),
h('div', { class: 'ant-modal-footer' }, [
h('button', { class: 'ant-btn', onClick: () => emit('cancel') }, props.cancelText),
h(
'button',
{
class: 'ant-btn ant-btn-primary',
disabled: props.okButtonProps?.disabled,
onClick: () => emit('ok'),
},
props.okText,
),
]),
]),
],
),
])
}
},
})
const mountEditor = (options: { props: Record<string, unknown> }) =>
mount(WaveformAnnotationEditor, {
props: options.props as never,
global: { stubs: { Modal: modalStub, AModal: modalStub } },
})
describe('waveform annotation controls', () => { describe('waveform annotation controls', () => {
afterEach(() => {
document.body.innerHTML = ''
})
it('keeps dialog title ids unique across editor instances', () => { it('keeps dialog title ids unique across editor instances', () => {
const first = mount(WaveformAnnotationEditor, { const first = mountEditor({
props: { props: {
annotation: { id: 'first', seriesId: 'a', x: 1, y: 2, text: '说明' }, annotation: { id: 'first', seriesId: 'a', x: 1, y: 2, text: '说明' },
mode: 'edit', mode: 'edit',
}, },
}) })
const second = mount(WaveformAnnotationEditor, { const second = mountEditor({
props: { props: {
annotation: { id: 'second', seriesId: 'a', x: 1, y: 2, text: '说明' }, annotation: { id: 'second', seriesId: 'a', x: 1, y: 2, text: '说明' },
mode: 'edit', mode: 'edit',
}, },
}) })
const firstTitleId = first.get('h2').attributes('id') const firstTitleId = first.get('.ant-modal-title [id]').attributes('id')
const secondTitleId = second.get('h2').attributes('id') const secondTitleId = second.get('.ant-modal-title [id]').attributes('id')
expect(firstTitleId).toBeTruthy() expect(firstTitleId).toBeTruthy()
expect(secondTitleId).toBeTruthy() expect(secondTitleId).toBeTruthy()
@@ -31,8 +96,19 @@ describe('waveform annotation controls', () => {
expect(second.get('[role="dialog"]').attributes('aria-labelledby')).toBe(secondTitleId) expect(second.get('[role="dialog"]').attributes('aria-labelledby')).toBe(secondTitleId)
}) })
it.each(['add', 'edit'] as const)('shows the X-axis snapping hint in %s mode', (mode) => {
const wrapper = mountEditor({
props: {
annotation: { id: `hint-${mode}`, seriesId: 'a', x: 1, y: 2, text: '说明' },
mode,
},
})
expect(wrapper.get('[role="note"]').text()).toBe('修改 X 轴后失焦时会自动吸附最近的采样点')
})
it('allows changing the annotation series inside the editor', async () => { it('allows changing the annotation series inside the editor', async () => {
const wrapper = mount(WaveformAnnotationEditor, { const wrapper = mountEditor({
props: { props: {
annotation: { id: 'note', seriesId: 'a', x: 1, y: 2, text: '说明' }, annotation: { id: 'note', seriesId: 'a', x: 1, y: 2, text: '说明' },
mode: 'edit', mode: 'edit',
@@ -77,17 +153,15 @@ describe('waveform annotation controls', () => {
it('validates text and emits an immutable edited annotation with style defaults', async () => { it('validates text and emits an immutable edited annotation with style defaults', async () => {
const annotation = { id: 'note', seriesId: 'a', x: 1, y: 2, text: '' } const annotation = { id: 'note', seriesId: 'a', x: 1, y: 2, text: '' }
const wrapper = mount(WaveformAnnotationEditor, { const wrapper = mountEditor({
props: { annotation, mode: 'add' }, props: { annotation, mode: 'add' },
}) })
await flushPromises() await flushPromises()
expect(wrapper.get('textarea').attributes('maxlength')).toBe('40') expect(wrapper.get('textarea').attributes('maxlength')).toBe('40')
expect(wrapper.get('.waveform-annotation-editor__coordinates').text()).toContain( expect(wrapper.get('.waveform-annotation-editor__coordinates').text()).toContain('X1000.000')
'X (ms)1000.000',
)
expect(wrapper.get('.waveform-annotation-editor__coordinates').text()).toContain('Y2') expect(wrapper.get('.waveform-annotation-editor__coordinates').text()).toContain('Y2')
expect(wrapper.get('button.is-primary').attributes('disabled')).toBeDefined() expect(wrapper.get('button.ant-btn-primary').attributes('disabled')).toBeDefined()
await vi.waitFor(() => expect(wrapper.findAllComponents(ColorPicker)).toHaveLength(3), { await vi.waitFor(() => expect(wrapper.findAllComponents(ColorPicker)).toHaveLength(3), {
timeout: 5000, timeout: 5000,
}) })
@@ -113,7 +187,7 @@ describe('waveform annotation controls', () => {
colorPickers[1].vm.$emit('update:pureColor', 'rgba(51, 51, 51, 0.8)') colorPickers[1].vm.$emit('update:pureColor', 'rgba(51, 51, 51, 0.8)')
colorPickers[2].vm.$emit('update:pureColor', 'rgba(255, 255, 255, 0.5)') colorPickers[2].vm.$emit('update:pureColor', 'rgba(255, 255, 255, 0.5)')
await wrapper.vm.$nextTick() await wrapper.vm.$nextTick()
await wrapper.get('button.is-primary').trigger('click') await wrapper.get('button.ant-btn-primary').trigger('click')
const emitted = wrapper.emitted('confirm')?.[0]?.[0] as const emitted = wrapper.emitted('confirm')?.[0]?.[0] as
| { | {
@@ -133,7 +207,7 @@ describe('waveform annotation controls', () => {
}) })
it('formats annotation coordinates using the selected display context', () => { it('formats annotation coordinates using the selected display context', () => {
const wrapper = mount(WaveformAnnotationEditor, { const wrapper = mountEditor({
props: { props: {
annotation: { id: 'note', seriesId: 'series', x: 1, y: 0.0000001, text: '说明' }, annotation: { id: 'note', seriesId: 'series', x: 1, y: 0.0000001, text: '说明' },
mode: 'edit', mode: 'edit',
@@ -142,13 +216,53 @@ describe('waveform annotation controls', () => {
}) })
const coordinates = wrapper.get('.waveform-annotation-editor__coordinates').text() const coordinates = wrapper.get('.waveform-annotation-editor__coordinates').text()
expect(coordinates).toContain('X (s)1.000') expect(coordinates).toContain('X1.000')
expect(coordinates).toContain('Y0.0000001') expect(coordinates).toContain('Y0.0000001')
expect(coordinates).not.toContain('e-') expect(coordinates).not.toContain('e-')
}) })
it('emits valid manual time input and disables save for invalid input', async () => {
const wrapper = mountEditor({
props: {
annotation: { id: 'note', seriesId: 'series', x: 1, y: 2, text: '说明' },
mode: 'edit',
timeUnit: 'ms',
},
})
const input = wrapper.getComponent(InputNumber)
expect(input.props('controls')).toBe(true)
await input.vm.$emit('update:value', 1500)
expect(wrapper.emitted('time-change')).toBeUndefined()
await input.vm.$emit('blur')
expect(wrapper.emitted('time-change')).toEqual([['1500']])
await input.vm.$emit('update:value', null)
await input.vm.$emit('blur')
expect(wrapper.get('[role="alert"]').text()).toBe('请输入有效的时间')
expect(wrapper.get('button.ant-btn-primary').attributes('disabled')).toBeDefined()
})
it('shows time validation errors and blocks confirmation', async () => {
const wrapper = mountEditor({
props: {
annotation: { id: 'note', seriesId: 'series', x: 1, y: 2, text: '说明' },
mode: 'edit',
timeError: '时间超出当前波形范围',
},
})
const input = wrapper.getComponent(InputNumber)
await input.vm.$emit('blur')
expect(
wrapper.get('.waveform-annotation-editor__coordinate-input').attributes('aria-invalid'),
).toBe('true')
expect(wrapper.get('[role="alert"]').text()).toBe('时间超出当前波形范围')
expect(wrapper.get('button.ant-btn-primary').attributes('disabled')).toBeDefined()
await wrapper.setProps({ timeError: '' })
expect(wrapper.find('[role="alert"]').exists()).toBe(false)
})
it('hydrates hexadecimal and rgba annotation colors', async () => { it('hydrates hexadecimal and rgba annotation colors', async () => {
const wrapper = mount(WaveformAnnotationEditor, { const wrapper = mountEditor({
props: { props: {
annotation: { annotation: {
id: 'colored-note', id: 'colored-note',
@@ -176,7 +290,7 @@ describe('waveform annotation controls', () => {
}) })
it('supports modal dismissal and live character counting', async () => { it('supports modal dismissal and live character counting', async () => {
const editor = mount(WaveformAnnotationEditor, { const editor = mountEditor({
props: { props: {
annotation: { id: 'note', seriesId: 'a', x: 1, y: 2, text: '原文字' }, annotation: { id: 'note', seriesId: 'a', x: 1, y: 2, text: '原文字' },
mode: 'edit', mode: 'edit',
@@ -184,23 +298,23 @@ describe('waveform annotation controls', () => {
}) })
expect(editor.get('[role="dialog"]').attributes('aria-modal')).toBe('true') expect(editor.get('[role="dialog"]').attributes('aria-modal')).toBe('true')
expect(editor.get('h2').text()).toBe('编辑标注') expect(editor.get('.ant-modal-title').text()).toBe('编辑标注')
await editor.get('textarea').setValue('三字说明') await editor.get('textarea').setValue('三字说明')
expect(editor.get('.waveform-annotation-editor__label-row').text()).toContain('4/40') expect(editor.get('.waveform-annotation-editor__label-row').text()).toContain('4/40')
await editor.get('textarea').trigger('keydown', { key: 'Escape' }) await editor.get('textarea').trigger('keydown', { key: 'Escape' })
await editor.get('.waveform-annotation-editor').trigger('click') await editor.get('.ant-modal-wrap').trigger('click')
expect(editor.emitted('cancel')).toHaveLength(2) expect(editor.emitted('cancel')).toHaveLength(2)
}) })
it('supports cancellation and context menu actions', async () => { it('supports cancellation and context menu actions', async () => {
const editor = mount(WaveformAnnotationEditor, { const editor = mountEditor({
props: { props: {
annotation: { id: 'note', seriesId: 'a', x: 1, y: 2, text: '原文字' }, annotation: { id: 'note', seriesId: 'a', x: 1, y: 2, text: '原文字' },
mode: 'edit', mode: 'edit',
}, },
}) })
await editor.findAll('button')[0].trigger('click') await editor.get('.ant-modal-close').trigger('click')
expect(editor.emitted('cancel')).toHaveLength(1) expect(editor.emitted('cancel')).toHaveLength(1)
const menu = mount(WaveformAnnotationContextMenu, { const menu = mount(WaveformAnnotationContextMenu, {

View File

@@ -56,6 +56,20 @@ describe('waveform annotation markup', () => {
).toBeNull() ).toBeNull()
}) })
it('snaps outside and between samples to the nearest complete-data point', () => {
const points = [
{ x: 1, y: 10 },
{ x: 2, y: 20 },
{ x: 4, y: 40 },
]
expect(findNearestPointByX(points, -10)).toEqual(points[0])
expect(findNearestPointByX(points, 3.1)).toEqual(points[2])
expect(findNearestPointByX(points, 10)).toEqual(points[2])
expect(findNearestPointByX(points, 2)).toEqual(points[1])
expect(findNearestPointByX([{ x: 2, y: 20 }], 2.1)).toEqual({ x: 2, y: 20 })
expect(findNearestPointByX(points, Number.NaN)).toBeNull()
})
it('snaps annotations to nearest sample points while using interpolation for distance calculation', () => { it('snaps annotations to nearest sample points while using interpolation for distance calculation', () => {
const track = createTrack(0, 'series', 0, [ const track = createTrack(0, 'series', 0, [
{ x: 0, y: 0 }, { x: 0, y: 0 },
@@ -89,14 +103,16 @@ describe('waveform annotation markup', () => {
expect(interpolateAnnotationPoint(points, 2, 'none')).toEqual({ x: 2, y: 10 }) expect(interpolateAnnotationPoint(points, 2, 'none')).toEqual({ x: 2, y: 10 })
}) })
it('omits interpolated candidates for point-only series between samples', () => { it('uses the nearest screen-space sample for point-only series', () => {
const pointOnly = createTrack(0, 'points', 0, [ const pointOnly = createTrack(0, 'points', 0, [
{ x: 0, y: 2 }, { x: 0, y: 2 },
{ x: 2, y: 10 }, { x: 2, y: 10 },
]) ])
pointOnly.series.lineType = 'none' pointOnly.series.lineType = 'none'
expect(findAnnotationSeriesCandidates([pointOnly], 1, 100, 50)).toEqual([]) expect(findAnnotationSeriesCandidates([pointOnly], 1, 5, 80)).toMatchObject([
{ point: { x: 0, y: 2 }, screenX: 0, screenY: 80, distance: 5 },
])
}) })
it('sorts line candidates by screen distance and keeps series metadata', () => { it('sorts line candidates by screen distance and keeps series metadata', () => {

View File

@@ -25,6 +25,25 @@ export const ANNOTATION_CONNECTOR_LENGTH = 32
const pointBisector = bisector((point: { x: number }) => point.x) const pointBisector = bisector((point: { x: number }) => point.x)
function findNearestPointOnScreen(
track: AnnotationTrackLayout,
pointerX: number,
pointerY: number,
): { point: { x: number; y: number }; screenX: number; screenY: number; distance: number } | null {
let nearest: { point: { x: number; y: number }; screenX: number; screenY: number; distance: number } | null =
null
for (const point of track.series.points) {
if (!Number.isFinite(point.x) || !Number.isFinite(point.y)) continue
const screenX = track.xScale(point.x)
const screenY = track.top + track.yScale(point.y)
const distance = Math.hypot(screenX - pointerX, screenY - pointerY)
if (!nearest || distance < nearest.distance) {
nearest = { point, screenX, screenY, distance }
}
}
return nearest
}
export function findNearestPointByX( export function findNearestPointByX(
points: Array<{ x: number; y: number }>, points: Array<{ x: number; y: number }>,
xValue: number, xValue: number,
@@ -74,6 +93,20 @@ export function findAnnotationSeriesCandidates(
): AnnotationSeriesCandidate[] { ): AnnotationSeriesCandidate[] {
return tracks return tracks
.flatMap((track): AnnotationSeriesCandidate[] => { .flatMap((track): AnnotationSeriesCandidate[] => {
if (track.series.lineType === 'none') {
const nearest = findNearestPointOnScreen(track, pointerX, pointerY)
if (!nearest) return []
return [
{
trackIndex: track.index,
seriesId: track.series.id,
name: track.series.name?.trim() || track.series.id,
color: track.series.color || DEFAULT_ANNOTATION_STYLE.borderColor,
unit: track.series.unit,
...nearest,
},
]
}
const interpolatedPoint = interpolateAnnotationPoint( const interpolatedPoint = interpolateAnnotationPoint(
track.series.points, track.series.points,
xValue, xValue,

View File

@@ -1,5 +1,5 @@
import { pointer, type ScaleLinear } from 'd3' import { pointer, type ScaleLinear } from 'd3'
import type { ComputedRef, Ref } from 'vue' import { ref, type ComputedRef, type Ref } from 'vue'
import type { WaveformAnnotation, WaveformInteractionMode } from '../data/types' import type { WaveformAnnotation, WaveformInteractionMode } from '../data/types'
import { findClosestTrackAtPointer } from '../core/layout' import { findClosestTrackAtPointer } from '../core/layout'
@@ -9,7 +9,7 @@ import {
ANNOTATION_AMBIGUITY_DISTANCE, ANNOTATION_AMBIGUITY_DISTANCE,
ANNOTATION_HIT_RADIUS, ANNOTATION_HIT_RADIUS,
findAnnotationSeriesCandidates, findAnnotationSeriesCandidates,
interpolateAnnotationPoint, findNearestPointByX,
} from './markup' } from './markup'
import type { import type {
AnnotationEditorAnchor, AnnotationEditorAnchor,
@@ -78,6 +78,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
activeInteractionMode, activeInteractionMode,
isPresentationMode, isPresentationMode,
} = context } = context
const timeError = ref('')
function toggleSeriesVisibility(seriesId: string) { function toggleSeriesVisibility(seriesId: string) {
if (!chartSeries.value.some((series) => series.id === seriesId)) return if (!chartSeries.value.some((series) => series.id === seriesId)) return
@@ -127,6 +128,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
anchor: AnnotationEditorAnchor, anchor: AnnotationEditorAnchor,
candidates: AnnotationSeriesCandidate[], candidates: AnnotationSeriesCandidate[],
) { ) {
timeError.value = ''
annotationInteraction.openCreate(hit, makeAnnotationId, anchor) annotationInteraction.openCreate(hit, makeAnnotationId, anchor)
editorSeriesOptions.value = candidates editorSeriesOptions.value = candidates
const draft = annotationInteraction.editorDraft.value const draft = annotationInteraction.editorDraft.value
@@ -140,7 +142,6 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
} }
} }
} }
function changeDraftSeries(seriesId: string) { function changeDraftSeries(seriesId: string) {
if (isPresentationMode.value) return if (isPresentationMode.value) return
const draft = annotationInteraction.editorDraft.value const draft = annotationInteraction.editorDraft.value
@@ -149,24 +150,61 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
item.seriesList.some((series) => series.id === seriesId), item.seriesList.some((series) => series.id === seriesId),
) )
const series = track?.seriesList.find((item) => item.id === seriesId) const series = track?.seriesList.find((item) => item.id === seriesId)
const point = const validPoints = series?.points.filter(
series && draft (item) => Number.isFinite(item.x) && Number.isFinite(item.y),
? interpolateAnnotationPoint(series.points, draft.annotation.x, series.lineType) )
: null const point = validPoints && draft ? findNearestPointByX(validPoints, draft.annotation.x) : null
if (!draft || !candidate || !track || !point) return if (!draft || !candidate || !track || !validPoints?.length) {
timeError.value = '当前波形没有有效数据'
return
}
if (draft.annotation.x < validPoints[0].x || draft.annotation.x > validPoints.at(-1)!.x) {
timeError.value = '时间超出当前波形范围'
return
}
timeError.value = ''
draft.annotation = { draft.annotation = {
...draft.annotation, ...draft.annotation,
seriesId, seriesId,
y: point.y, y: point!.y,
style: { ...draft.annotation.style, borderColor: candidate.color }, style: { ...draft.annotation.style, borderColor: candidate.color },
} }
} }
function changeDraftTime(displayValue: string) {
if (isPresentationMode.value) return
const draft = annotationInteraction.editorDraft.value
const displayTime = Number(displayValue)
if (!draft || !Number.isFinite(displayTime)) {
timeError.value = '请输入有效的时间'
return
}
const rawTime = props.timeUnit === 'ms' ? displayTime / 1000 : displayTime
const track = trackLayouts.value.find((item) =>
item.seriesList.some((series) => series.id === draft.annotation.seriesId),
)
const series = track?.seriesList.find((item) => item.id === draft.annotation.seriesId)
const validPoints = series?.points.filter(
(point) => Number.isFinite(point.x) && Number.isFinite(point.y),
)
if (!validPoints?.length) {
timeError.value = '当前波形没有有效数据'
return
}
const firstX = validPoints[0].x
const lastX = validPoints[validPoints.length - 1].x
if (rawTime < firstX || rawTime > lastX) {
timeError.value = '时间超出当前波形范围'
return
}
const point = findNearestPointByX(validPoints, rawTime)!
timeError.value = ''
draft.annotation = { ...draft.annotation, x: point.x, y: point.y }
}
function cancelAnnotation() { function cancelAnnotation() {
timeError.value = ''
annotationInteraction.closeEditor() annotationInteraction.closeEditor()
editorSeriesOptions.value = [] editorSeriesOptions.value = []
} }
function resolveTrackAtPointer( function resolveTrackAtPointer(
pointerX: number, pointerX: number,
pointerY: number, pointerY: number,
@@ -292,6 +330,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
function editContextAnnotation() { function editContextAnnotation() {
if (isPresentationMode.value) return if (isPresentationMode.value) return
timeError.value = ''
const menu = annotationInteraction.contextMenu.value const menu = annotationInteraction.contextMenu.value
const annotation = props.annotations.find((item) => item.id === menu?.annotationId) const annotation = props.annotations.find((item) => item.id === menu?.annotationId)
if (!annotation) return if (!annotation) return
@@ -328,7 +367,7 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
function confirmAnnotation(annotation: WaveformAnnotation) { function confirmAnnotation(annotation: WaveformAnnotation) {
if (isPresentationMode.value) return if (isPresentationMode.value) return
const draft = annotationInteraction.editorDraft.value const draft = annotationInteraction.editorDraft.value
if (!draft) return if (!draft || timeError.value) return
if (draft.mode === 'add') { if (draft.mode === 'add') {
emit('update:annotations', [...props.annotations, annotation]) emit('update:annotations', [...props.annotations, annotation])
emit('annotation-create', annotation) emit('annotation-create', annotation)
@@ -345,6 +384,8 @@ export function useWaveformChartAnnotations(context: AnnotationContext) {
return { return {
toggleSeriesVisibility, toggleSeriesVisibility,
changeDraftSeries, changeDraftSeries,
changeDraftTime,
timeError,
cancelAnnotation, cancelAnnotation,
resolveTrackAtPointer, resolveTrackAtPointer,
handleAnnotationClick, handleAnnotationClick,

View File

@@ -0,0 +1,29 @@
import { describe, expect, it } from 'vitest'
import { channelColors } from './constants'
describe('channelColors', () => {
it('contains the referenced dark palette followed by twenty supplemental colors', () => {
expect(channelColors).toHaveLength(30)
expect(channelColors.slice(0, 10)).toEqual([
'#0960bd',
'#ff7f0e',
'#2ca02c',
'#d62728',
'#9467bd',
'#8c564b',
'#e377c2',
'#7f7f7f',
'#bcbd22',
'#17becf',
])
expect(new Set(channelColors).size).toBe(channelColors.length)
})
it('wraps around after the thirtieth color for automatic series assignment', () => {
expect(channelColors[0]).toBe('#0960bd')
expect(channelColors[29]).toBe('#31572c')
expect(channelColors[30 % channelColors.length]).toBe(channelColors[0])
expect(channelColors[31 % channelColors.length]).toBe(channelColors[1])
})
})

View File

@@ -125,19 +125,41 @@ export const TITLE_LINE_HEIGHT = 1.2
// ==================== 样式常量 ==================== // ==================== 样式常量 ====================
/** /**
* 通道默认颜色列表 * 多系列波形的默认颜色列表
*
* 颜色保持较深并具备足够辨识度,避免大量系列同时绘制时使用浅色导致线条难以观察。
*/ */
export const channelColors = [ export const channelColors = [
'#0960bd', '#0960bd',
'#ff7f0e', '#ff7f0e',
'#389e0d', '#2ca02c',
'#cf1322', '#d62728',
'#531dab', '#9467bd',
'#08979c', '#8c564b',
'#c41d7f', '#e377c2',
'#434343', '#7f7f7f',
'#7cb305', '#bcbd22',
'#1d39c4', '#17becf',
'#005f73',
'#0a9396',
'#ae2012',
'#bb3e03',
'#ca6702',
'#ee9b00',
'#9b2226',
'#3a0ca3',
'#4361ee',
'#4895ef',
'#560bad',
'#7209b7',
'#b5179e',
'#f72585',
'#006d77',
'#2f3e46',
'#354f52',
'#52796f',
'#800f2f',
'#31572c',
] ]
/** /**

View File

@@ -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'

View File

@@ -13,6 +13,7 @@ import type { NormalizedWaveformGridLineOptions } from './grid'
*/ */
export interface DisplaySeries { export interface DisplaySeries {
id: string id: string
shotNo?: string
trackId?: string trackId?: string
name: string name: string
unit?: string unit?: string
@@ -63,6 +64,7 @@ export interface WaveformYAxisLayout {
*/ */
export interface HoveredSeriesPoint { export interface HoveredSeriesPoint {
id: string id: string
shotNo?: string
name: string name: string
unit?: string unit?: string
color: string color: string

View File

@@ -78,12 +78,14 @@ export function useWaveformChartController(
}, },
{ deep: true }, { deep: true },
) )
const selection = ref<ViewportSelectionState | null>(null) const selection = shallowRef<ViewportSelectionState | null>(null)
const spacePressed = ref(false) const spacePressed = ref(false)
const pointerInsideChart = ref(false) const pointerInsideChart = ref(false)
let handleBeforeDataReferenceChange: () => void = () => undefined
let handleDataReferenceChange: () => void = () => undefined let handleDataReferenceChange: () => void = () => undefined
const preparedSeries = usePreparedWaveformSeries( const preparedSeries = usePreparedWaveformSeries(
() => props.data, () => props.data,
() => handleBeforeDataReferenceChange(),
() => handleDataReferenceChange(), () => handleDataReferenceChange(),
) )
@@ -127,6 +129,7 @@ export function useWaveformChartController(
const { const {
chartSeries, chartSeries,
chartTracks, chartTracks,
trackLayouts,
gridOptions, gridOptions,
pageCount, pageCount,
pagedTracks, pagedTracks,
@@ -138,7 +141,6 @@ export function useWaveformChartController(
initialXDomain, initialXDomain,
sharedZoomDomain, sharedZoomDomain,
resolveInitialTrackDomain, resolveInitialTrackDomain,
trackLayouts,
annotationLayoutsForTrack, annotationLayoutsForTrack,
resolveSeriesYScale, resolveSeriesYScale,
} = layout } = layout
@@ -254,6 +256,7 @@ export function useWaveformChartController(
gridOptions, gridOptions,
chartSeries, chartSeries,
chartTracks, chartTracks,
trackLayouts,
innerWidth, innerWidth,
innerHeight, innerHeight,
activeInteractionMode, activeInteractionMode,
@@ -261,6 +264,7 @@ export function useWaveformChartController(
internalHiddenSeriesIds, internalHiddenSeriesIds,
independentTransforms, independentTransforms,
independentYDomains, independentYDomains,
resolveInitialTrackDomain,
annotationInteraction, annotationInteraction,
editorSeriesOptions, editorSeriesOptions,
isPresentationMode, isPresentationMode,
@@ -272,6 +276,7 @@ export function useWaveformChartController(
cancelPendingHover: hover.cancelPendingHover, cancelPendingHover: hover.cancelPendingHover,
clearZoomBindings: zoom.clearZoomBindings, clearZoomBindings: zoom.clearZoomBindings,
}) })
handleBeforeDataReferenceChange = lifecycle.handleBeforeDataReferenceChange
handleDataReferenceChange = lifecycle.handleDataReferenceChange handleDataReferenceChange = lifecycle.handleDataReferenceChange
return reactive({ return reactive({

View File

@@ -11,8 +11,9 @@ import {
import type { AnnotationSeriesCandidate } from '../annotation' import type { AnnotationSeriesCandidate } from '../annotation'
import type { useWaveformAnnotationInteraction } 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 type { ResolvedWaveformChartProps, WaveformChartEmit } from './waveformChartTypes'
import { constrainZoomDomain, transformForDomain } from '../interaction/zoomConstraints'
interface LifecycleContext { interface LifecycleContext {
props: ResolvedWaveformChartProps props: ResolvedWaveformChartProps
@@ -35,6 +36,7 @@ interface LifecycleContext {
gridOptions: ComputedRef<{ rowCount: number; columnCount: number }> gridOptions: ComputedRef<{ rowCount: number; columnCount: number }>
chartSeries: ComputedRef<DisplaySeries[]> chartSeries: ComputedRef<DisplaySeries[]>
chartTracks: ComputedRef<DisplayTrack[]> chartTracks: ComputedRef<DisplayTrack[]>
trackLayouts: ComputedRef<TrackLayout[]>
innerWidth: ComputedRef<number> innerWidth: ComputedRef<number>
innerHeight: ComputedRef<number> innerHeight: ComputedRef<number>
activeInteractionMode: ComputedRef<string | undefined> activeInteractionMode: ComputedRef<string | undefined>
@@ -42,6 +44,7 @@ interface LifecycleContext {
internalHiddenSeriesIds: Ref<Set<string>> internalHiddenSeriesIds: Ref<Set<string>>
independentTransforms: ShallowRef<ZoomTransform[]> independentTransforms: ShallowRef<ZoomTransform[]>
independentYDomains: Ref<Record<number, [number, number]>> independentYDomains: Ref<Record<number, [number, number]>>
resolveInitialTrackDomain: (track: TrackLayout) => [number, number]
annotationInteraction: ReturnType<typeof useWaveformAnnotationInteraction> annotationInteraction: ReturnType<typeof useWaveformAnnotationInteraction>
editorSeriesOptions: Ref<AnnotationSeriesCandidate[]> editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
isPresentationMode: ComputedRef<boolean> isPresentationMode: ComputedRef<boolean>
@@ -87,6 +90,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
gridOptions, gridOptions,
chartSeries, chartSeries,
chartTracks, chartTracks,
trackLayouts,
innerWidth, innerWidth,
innerHeight, innerHeight,
activeInteractionMode, activeInteractionMode,
@@ -94,6 +98,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
internalHiddenSeriesIds, internalHiddenSeriesIds,
independentTransforms, independentTransforms,
independentYDomains, independentYDomains,
resolveInitialTrackDomain,
annotationInteraction, annotationInteraction,
editorSeriesOptions, editorSeriesOptions,
isPresentationMode, isPresentationMode,
@@ -139,6 +144,27 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
emit('page-change', nextPage, pageCount.value) 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() { function handleDataReferenceChange() {
if (props.displayMode === 'independent') { if (props.displayMode === 'independent') {
const currentTransforms = independentTransforms.value const currentTransforms = independentTransforms.value
@@ -148,7 +174,24 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
} }
clearHover() clearHover()
editorSeriesOptions.value = [] 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( watch(
@@ -158,8 +201,10 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
() => props.zoomable, () => props.zoomable,
isPresentationMode, isPresentationMode,
() => props.minZoomSpan, () => props.minZoomSpan,
() => props.minVisiblePoints,
() => props.initialXDomain, () => props.initialXDomain,
() => props.initialXDomains, () => props.initialXDomains,
() => props.xDomainStrategy,
() => props.displayMode, () => props.displayMode,
() => chartTracks.value.length, () => chartTracks.value.length,
currentPage, currentPage,
@@ -324,5 +369,5 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
editorSeriesOptions.value = [] editorSeriesOptions.value = []
}) })
return { goToPage, handleDataReferenceChange } return { goToPage, handleBeforeDataReferenceChange, handleDataReferenceChange }
} }

View File

@@ -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())) const preparedSeries = shallowRef<PreparedWaveformSeries[]>(prepareWaveformSeries(data()))
watch(data, (nextData) => { watch(data, (nextData) => {
onBeforeDataChange()
preparedSeries.value = prepareWaveformSeries(nextData) preparedSeries.value = prepareWaveformSeries(nextData)
onDataChange() onDataChange()
}) })

View File

@@ -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 configuredDomain[0] < configuredDomain[1]
: [configuredDomain[1], configuredDomain[0]] ? configuredDomain
: [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:

View File

@@ -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
( (
@@ -95,16 +99,17 @@ export interface WaveformChartEmit {
(event: 'page-change', page: number, pageCount: number): void (event: 'page-change', page: number, pageCount: number): void
} }
export interface ViewportSelectionState { interface ViewportSelectionBase {
trackIndex: number trackIndex: number
independent: boolean independent: boolean
overlay: SVGRectElement
startX: number startX: number
startY: number startY: number
currentX: number currentX: number
currentY: number currentY: number
pointerId: number pointerId: number
mode: 'box' | 'pan'
xDomain: [number, number] xDomain: [number, number]
yDomains: Record<string, [number, number]> yDomains: Record<string, [number, number]>
} }
export type ViewportSelectionState =
(ViewportSelectionBase & { kind: 'box' }) | (ViewportSelectionBase & { kind: 'pan' })

View File

@@ -0,0 +1,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])
})
})

View 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
}

View File

@@ -9,7 +9,9 @@ export type {
WaveformDisplayMode, WaveformDisplayMode,
WaveformOverlayMode, WaveformOverlayMode,
WaveformInteractionMode, WaveformInteractionMode,
WaveformXDomainStrategy,
WaveformZoomEndPayload, WaveformZoomEndPayload,
WaveformZoomResetPayload,
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,

View File

@@ -38,10 +38,49 @@ describe('WaveformTooltip', () => {
const tooltip = mountTooltip(100, 200).get('.waveform-tooltip') const tooltip = mountTooltip(100, 200).get('.waveform-tooltip')
expect(tooltip.attributes('style')).toContain('left: 8px') expect(tooltip.attributes('style')).toContain('left: 8px')
expect(tooltip.attributes('style')).toContain('max-width: 184px')
expect(tooltip.attributes('style')).not.toContain('right:') expect(tooltip.attributes('style')).not.toContain('right:')
}) })
it('shows resolved asymmetric errors beside the hovered value', () => { 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('omits units and errors from the strict x/y value format', () => {
const pointWithErrors = { x: 1, y: 12, error: 1, upperError: 2 } const pointWithErrors = { x: 1, y: 12, error: 1, upperError: 2 }
const wrapper = mount(WaveformTooltip, { const wrapper = mount(WaveformTooltip, {
props: { props: {
@@ -63,10 +102,12 @@ describe('WaveformTooltip', () => {
}, },
}) })
expect(wrapper.get('.waveform-tooltip__series small').text()).toBe('(+2 / -1)') expect(wrapper.get('.waveform-tooltip__value').text()).toBe('(x:1 y:12)')
expect(wrapper.get('.waveform-tooltip__value').text()).not.toContain('C')
expect(wrapper.get('.waveform-tooltip__value').text()).not.toContain('+2')
}) })
it('keeps a formatted value and its unit in one value container', () => { it('renders the configured shot number, channel, and formatted coordinates', () => {
const hoveredPoint = { x: 1, y: -1405.4932 } const hoveredPoint = { x: 1, y: -1405.4932 }
const wrapper = mount(WaveformTooltip, { const wrapper = mount(WaveformTooltip, {
props: { props: {
@@ -78,6 +119,7 @@ describe('WaveformTooltip', () => {
{ {
trackIndex: 0, trackIndex: 0,
name: 'ENG8KJXAc(10001)', name: 'ENG8KJXAc(10001)',
shotNo: '炮 7',
color: '#ffb43b', color: '#ffb43b',
unit: 'A', unit: 'A',
point: hoveredPoint, point: hoveredPoint,
@@ -88,26 +130,31 @@ describe('WaveformTooltip', () => {
}, },
}) })
expect(wrapper.get('.waveform-tooltip__value').text()).toBe('-1,405.4932 A') expect(wrapper.get('.waveform-tooltip__series-label').text()).toBe('炮 7 ENG8KJXAc(10001)(A)')
expect(wrapper.get('.waveform-tooltip__value').text()).toBe('(x:1,000 y:-1,405.4932)')
}) })
it('formats tooltip time with at most four decimal places', () => { it('renders each series row with the formatted x coordinate and series label', () => {
const wrapper = mount(WaveformTooltip, { const wrapper = mount(WaveformTooltip, {
props: { props: {
visible: true, visible: true,
position: { x: 10, y: 10 }, position: { x: 10, y: 10 },
timeUnit: 's', timeUnit: 's',
hoveredPoint: { x: 1.234567, y: 12 }, hoveredPoint: { x: 1.234567, y: 12 },
seriesPoints: [], seriesPoints: [
{ trackIndex: 0, name: '温度', color: '#f00', point: { x: 1.234567, y: 12 } },
],
containerWidth: 400, containerWidth: 400,
containerHeight: 300, containerHeight: 300,
}, },
}) })
expect(wrapper.get('.waveform-tooltip__time').text()).toBe('s: 1.2346') expect(wrapper.find('.waveform-tooltip__time').exists()).toBe(false)
expect(wrapper.get('.waveform-tooltip__series-label').text()).toBe('未配置炮号: 温度')
expect(wrapper.get('.waveform-tooltip__value').text()).toBe('(x:1.2346 y:12)')
}) })
it('omits the error label when both resolved errors are zero', () => { it('uses the fallback shot number when shotNo is blank', () => {
const point = { x: 1, y: 12 } const point = { x: 1, y: 12 }
const wrapper = mount(WaveformTooltip, { const wrapper = mount(WaveformTooltip, {
props: { props: {
@@ -121,6 +168,6 @@ describe('WaveformTooltip', () => {
}, },
}) })
expect(wrapper.find('.waveform-tooltip__series small').exists()).toBe(false) expect(wrapper.get('.waveform-tooltip__series-label').text()).toBe('未配置炮号: 温度')
}) })
}) })

View File

@@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
import { resolveWaveformPointErrors } from '../../core'
import { formatTooltipNumber, formatTooltipTime } from '../../utils' import { formatTooltipNumber, formatTooltipTime } from '../../utils'
import type { WaveformPoint } from '../data/types' import type { WaveformPoint } from '../data/types'
interface SeriesPoint { interface SeriesPoint {
trackIndex: number trackIndex: number
name: string name: string
shotNo?: string
color: string color: string
unit?: string unit?: string
point: WaveformPoint point: WaveformPoint
@@ -33,32 +33,69 @@ const props = defineProps<Props>()
const tooltipGap = 12 const tooltipGap = 12
const containerPadding = 8 const containerPadding = 8
const tooltipMaxWidth = 238 const tooltipPlacementWidth = 238
const tooltipMaxWidth = 560
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 timeText = formatTooltipTime(props.hoveredPoint?.x ?? seriesPoint.point.x, props.timeUnit)
return `${seriesPoint.shotNo?.trim() || '未配置炮号'} ${seriesPoint.name}${
seriesPoint.unit ? `(${seriesPoint.unit})` : ''
} (x:${timeText} y:${formatTooltipNumber(seriesPoint.point.y)})`
}
function estimateTooltipHeight(width: number): number {
const seriesLines = props.seriesPoints.reduce(
(total, seriesPoint) => total + estimateLineCount(formatSeriesText(seriesPoint), width),
0,
)
return 16 + tooltipLineHeight * seriesLines + 5
}
const tooltipStyle = computed(() => { const tooltipStyle = computed(() => {
if (!props.visible || !props.hoveredPoint) return { display: 'none' } if (!props.visible || !props.hoveredPoint) return { display: 'none' }
const estimatedHeight = 44 + props.seriesPoints.length * 22
const rightPlacement = props.position.x + tooltipGap 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 = const horizontalStyle =
rightPlacement + tooltipMaxWidth <= props.containerWidth - containerPadding rightPlacement + tooltipPlacementWidth <= props.containerWidth - containerPadding
? { left: `${rightPlacement}px` } ? {
left: `${rightPlacement}px`,
maxWidth: `${Math.min(tooltipMaxWidth, rightAvailableWidth)}px`,
}
: leftPlacement >= containerPadding : 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)
return { return {
...horizontalStyle, ...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) - 8,
),
)}px`,
} }
}) })
function formatError(point: WaveformPoint): string | null {
const { lower, upper } = resolveWaveformPointErrors(point)
if (lower === 0 && upper === 0) return null
return `(+${formatTooltipNumber(upper)} / -${formatTooltipNumber(lower)})`
}
</script> </script>
<template> <template>
@@ -67,20 +104,19 @@ function formatError(point: WaveformPoint): string | null {
class="waveform-tooltip waveform-chart__tooltip" class="waveform-tooltip waveform-chart__tooltip"
:style="tooltipStyle" :style="tooltipStyle"
> >
<span class="waveform-tooltip__time waveform-chart__tooltip-time">
{{ timeUnit }}: {{ formatTooltipTime(hoveredPoint.x, timeUnit) }}
</span>
<span <span
v-for="seriesPoint in seriesPoints" v-for="seriesPoint in seriesPoints"
:key="`${seriesPoint.trackIndex}-${seriesPoint.name}`" :key="`${seriesPoint.trackIndex}-${seriesPoint.name}`"
class="waveform-tooltip__series waveform-chart__tooltip-series" class="waveform-tooltip__series waveform-chart__tooltip-series"
> >
<i :style="{ backgroundColor: seriesPoint.color }" /> <i :style="{ backgroundColor: seriesPoint.color }" />
<strong v-if="seriesPoint.name">{{ seriesPoint.name }}:</strong> <span class="waveform-tooltip__series-content">
<span class="waveform-tooltip__value"> <span class="waveform-tooltip__series-label">{{ seriesPoint.shotNo?.trim() || '未配置炮号' }} {{
{{ formatTooltipNumber(seriesPoint.point.y) seriesPoint.name
}}{{ seriesPoint.unit ? ` ${seriesPoint.unit}` : '' }} }}<template v-if="seriesPoint.unit">({{ seriesPoint.unit }})</template></span>
<small v-if="formatError(seriesPoint.point)">{{ formatError(seriesPoint.point) }}</small> <span class="waveform-tooltip__value">(x:{{ formatTooltipTime(hoveredPoint.x, timeUnit) }} y:{{
formatTooltipNumber(seriesPoint.point.y)
}})</span>
</span> </span>
</span> </span>
</div> </div>
@@ -92,54 +128,64 @@ function formatError(point: WaveformPoint): string | null {
position: absolute; position: absolute;
z-index: 2; z-index: 2;
display: grid; display: grid;
gap: 3px; gap: 2px;
min-width: 180px; width: max-content;
max-width: 238px; max-width: min(560px, calc(100% - 16px));
padding: 8px 10px; padding: 9px 12px;
color: #333; color: #505050;
font: font: 14px/1.35 Arial, sans-serif;
12px/1.45 ui-monospace,
SFMono-Regular,
Consolas,
monospace;
pointer-events: none; pointer-events: none;
background: #fff; background: #fff;
border: 1px solid #d9d9d9; border: 1px solid #e3e7eb;
border-radius: 2px; border-radius: 4px;
box-shadow: 0 4px 12px rgb(16 24 40 / 12%); box-shadow: 0 3px 10px rgb(16 24 40 / 15%);
}
.waveform-tooltip__time {
padding-bottom: 4px;
border-bottom: 1px solid #f0f0f0;
} }
.waveform-tooltip__series { .waveform-tooltip__series {
display: grid; display: grid;
grid-template-columns: 8px minmax(0, 1fr) auto; grid-template-columns: 8px minmax(0, 1fr);
gap: 6px; gap: 6px;
align-items: center; align-items: start;
} }
.waveform-tooltip__series i { .waveform-tooltip__series i {
flex: 0 0 auto;
width: 8px; width: 8px;
height: 8px; height: 8px;
margin-top: 4px;
border-radius: 50%; border-radius: 50%;
} }
.waveform-tooltip__series strong { .waveform-tooltip__series-content {
display: flex;
flex-wrap: nowrap;
gap: 12px;
align-items: baseline;
min-width: 0;
}
.waveform-tooltip__series-label {
flex: 1 1 auto;
min-width: 0;
overflow: hidden; overflow: hidden;
font-weight: 600;
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.waveform-tooltip__value { .waveform-tooltip__value {
flex: 0 0 auto;
white-space: nowrap; white-space: nowrap;
} }
.waveform-tooltip__series strong {
font-weight: 600;
}
.waveform-tooltip__value {
color: #555;
}
.waveform-tooltip__series small { .waveform-tooltip__series small {
color: #667085; color: #667085;
white-space: nowrap;
} }
</style> </style>

View 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.
}
}

View File

@@ -85,6 +85,7 @@ export function useWaveformHover(context: HoverContext) {
point: WaveformPoint, point: WaveformPoint,
): HoveredSeriesPoint => ({ ): HoveredSeriesPoint => ({
id: series.id, id: series.id,
shotNo: series.shotNo,
name: series.name, name: series.name,
color: series.color, color: series.color,
unit: series.unit, unit: series.unit,
@@ -136,7 +137,7 @@ export function useWaveformHover(context: HoverContext) {
} }
const handleSharedPointerMove = (event: PointerEvent) => { const handleSharedPointerMove = (event: PointerEvent) => {
if (isPresentationMode.value) return if (isPresentationMode.value) return
if (selection.value?.overlay === event.currentTarget) { if (selection.value && !selection.value.independent) {
updateViewportDrag(event) updateViewportDrag(event)
return return
} }

View File

@@ -1,6 +1,5 @@
import { pointer, scaleLinear, zoomIdentity, type ZoomTransform } from 'd3' import { pointer, zoomIdentity, type ZoomTransform } from 'd3'
import { computed, nextTick, type ComputedRef, type Ref, type ShallowRef } from 'vue' import { computed, nextTick, shallowRef, type ComputedRef, type Ref, type ShallowRef } from 'vue'
import { MINIMUM_SELECTION_SIZE } from '../core/constants' import { MINIMUM_SELECTION_SIZE } from '../core/constants'
import type { DisplayTrack, TrackLayout } from '../core/types' import type { DisplayTrack, TrackLayout } from '../core/types'
import { hasFixedYDomainForTrack } from '../core/yDomain' import { hasFixedYDomainForTrack } from '../core/yDomain'
@@ -10,7 +9,9 @@ import type {
WaveformChartEmit, WaveformChartEmit,
} from '../core/waveformChartTypes' } from '../core/waveformChartTypes'
import type { AnnotationSeriesCandidate } from '../annotation' import type { AnnotationSeriesCandidate } from '../annotation'
import { tryReleasePointerCapture } from './pointerCapture'
import { transitionViewportInteraction } from './viewportInteractionState'
import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
interface ViewportContext { interface ViewportContext {
props: ResolvedWaveformChartProps props: ResolvedWaveformChartProps
emit: WaveformChartEmit emit: WaveformChartEmit
@@ -37,7 +38,6 @@ interface ViewportContext {
clearHover: () => void clearHover: () => void
resolveTrackAtPointer: (pointerX: number, pointerY: number) => TrackLayout | undefined resolveTrackAtPointer: (pointerX: number, pointerY: number) => TrackLayout | undefined
} }
export function useWaveformViewport(context: ViewportContext) { export function useWaveformViewport(context: ViewportContext) {
const { const {
props, props,
@@ -65,6 +65,23 @@ export function useWaveformViewport(context: ViewportContext) {
clearHover, clearHover,
resolveTrackAtPointer, resolveTrackAtPointer,
} = context } = 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 selectionBox = computed(() => {
const active = selection.value const active = selection.value
if (!active) return null if (!active) return null
@@ -76,48 +93,6 @@ export function useWaveformViewport(context: ViewportContext) {
height: Math.abs(active.currentY - active.startY), 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 clampDomain = (domain: [number, number], boundary: [number, number]): [number, number] => {
const span = domain[1] - domain[0] const span = domain[1] - domain[0]
const boundarySpan = boundary[1] - boundary[0] const boundarySpan = boundary[1] - boundary[0]
@@ -143,19 +118,22 @@ export function useWaveformViewport(context: ViewportContext) {
const [rawX, rawY] = pointer(event, overlay) const [rawX, rawY] = pointer(event, overlay)
const x = Math.max(0, Math.min(independent ? track.width : innerWidth.value, rawX)) const x = Math.max(0, Math.min(independent ? track.width : innerWidth.value, rawX))
const y = Math.max(0, Math.min(independent ? track.height : innerHeight.value, rawY)) const y = Math.max(0, Math.min(independent ? track.height : innerHeight.value, rawY))
selection.value = { const started = transitionViewportInteraction(selection.value, {
trackIndex, type: 'begin',
independent, gesture: {
overlay, trackIndex,
startX: x, independent,
startY: y, startX: x,
currentX: x, startY: y,
currentY: y, pointerId: event.pointerId,
pointerId: event.pointerId, kind: panRequested ? 'pan' : 'box',
mode: panRequested ? 'pan' : 'box', xDomain: track.xScale.domain() as [number, number],
xDomain: track.xScale.domain() as [number, number], yDomains: currentYDomains(),
yDomains: currentYDomains(), },
} })
if (!started.accepted) return
selection.value = started.state
activeOverlay.value = overlay
overlay.setPointerCapture?.(event.pointerId) overlay.setPointerCapture?.(event.pointerId)
clearHover() clearHover()
event.preventDefault() event.preventDefault()
@@ -213,27 +191,34 @@ export function useWaveformViewport(context: ViewportContext) {
const updateViewportDrag = (event: PointerEvent) => { const updateViewportDrag = (event: PointerEvent) => {
if (isPresentationMode.value) return if (isPresentationMode.value) return
const active = selection.value const active = selection.value
if (!active || event.pointerId !== active.pointerId) return const overlay = activeOverlay.value
if (!active || !overlay || event.pointerId !== active.pointerId) return
const track = trackLayouts.value.find((item) => item.index === active.trackIndex) const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
if (!track) return if (!track) return
const [rawX, rawY] = pointer(event, active.overlay) const [rawX, rawY] = pointer(event, overlay)
active.currentX = Math.max( const currentX = Math.max(
0, 0,
Math.min(active.independent ? track.width : innerWidth.value, rawX), Math.min(active.independent ? track.width : innerWidth.value, rawX),
) )
active.currentY = Math.max( const currentY = Math.max(
0, 0,
Math.min(active.independent ? track.height : innerHeight.value, rawY), Math.min(active.independent ? track.height : innerHeight.value, rawY),
) )
selection.value = { ...active } const transition = transitionViewportInteraction(selection.value, {
if (active.mode === 'pan') applyPan(active, track) 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() event.preventDefault()
} }
const cancelViewportDrag = (event?: PointerEvent) => { const cancelViewportDrag = (event?: PointerEvent) => {
const active = selection.value const active = selection.value
if (!active || (event && event.pointerId !== active.pointerId)) return if (!active || (event && event.pointerId !== active.pointerId)) return
active.overlay.releasePointerCapture?.(active.pointerId) cleanupViewportDrag(active.pointerId, event)
selection.value = null
} }
const applyBoxZoom = (active: ViewportSelectionState) => { const applyBoxZoom = (active: ViewportSelectionState) => {
const track = trackLayouts.value.find((item) => item.index === active.trackIndex) const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
@@ -249,9 +234,14 @@ export function useWaveformViewport(context: ViewportContext) {
) )
if (right - left < MINIMUM_SELECTION_SIZE) return if (right - left < MINIMUM_SELECTION_SIZE) return
const baseXDomain = active.independent ? resolveInitialTrackDomain(track) : initialXDomain.value 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( const xDomain = constrainZoomDomain(
[track.xScale.invert(left), track.xScale.invert(right)], [track.xScale.invert(left), track.xScale.invert(right)],
baseXDomain, baseXDomain,
groups,
props,
) )
if (active.independent) { if (active.independent) {
const next = [...independentTransforms.value] const next = [...independentTransforms.value]
@@ -300,15 +290,38 @@ export function useWaveformViewport(context: ViewportContext) {
} }
const active = selection.value const active = selection.value
if (!active || event.pointerId !== active.pointerId) return if (!active || event.pointerId !== active.pointerId) return
updateViewportDrag(event) const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
active.overlay.releasePointerCapture?.(active.pointerId) 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 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) void nextTick(configureZoom)
return return
} }
if (Math.abs(active.currentX - active.startX) >= MINIMUM_SELECTION_SIZE) { if (Math.abs(completed.currentX - completed.startX) >= MINIMUM_SELECTION_SIZE) {
applyBoxZoom(active) applyBoxZoom(completed)
} }
} }
const resetViewport = (trackIndex?: number) => { const resetViewport = (trackIndex?: number) => {
@@ -333,11 +346,24 @@ 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,
beginViewportDrag, beginViewportDrag,

View File

@@ -15,6 +15,12 @@ import { WHEEL_ZOOM_DEBOUNCE_MS, ZOOM_CONSTRAINTS } from '../core/constants'
import type { TrackLayout } from '../core/types' import type { TrackLayout } from '../core/types'
import type { ResolvedWaveformChartProps, WaveformChartEmit } from '../core/waveformChartTypes' import type { ResolvedWaveformChartProps, WaveformChartEmit } from '../core/waveformChartTypes'
import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle' import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
import {
constrainZoomDomain,
resolveMinimumZoomSpan,
transformForDomain,
type ZoomSeriesGroup,
} from './zoomConstraints'
interface ZoomContext { interface ZoomContext {
props: ResolvedWaveformChartProps props: ResolvedWaveformChartProps
@@ -216,24 +222,38 @@ export function useWaveformZoom(context: ZoomContext) {
.forEach((overlay) => select(overlay).on('.zoom', null)) .forEach((overlay) => select(overlay).on('.zoom', null))
zoomBehaviors.clear() zoomBehaviors.clear()
} }
const resolveMaximumZoomScale = (domain: [number, number]): number => { const resolveMaximumZoomScale = (
const minZoomSpan = props.minZoomSpan domain: [number, number],
if (!Number.isFinite(minZoomSpan) || (minZoomSpan ?? 0) <= 0) { groups: readonly ZoomSeriesGroup[],
return ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE ): number => {
}
const domainSpan = Math.abs(domain[1] - domain[0]) const domainSpan = Math.abs(domain[1] - domain[0])
if (!Number.isFinite(domainSpan) || domainSpan <= 0) return ZOOM_CONSTRAINTS.MIN_SCALE if (!Number.isFinite(domainSpan) || domainSpan <= 0) return ZOOM_CONSTRAINTS.MIN_SCALE
return Math.min( const minimumSpan = resolveMinimumZoomSpan(domain, groups, props)
ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE, return Math.max(
Math.max(ZOOM_CONSTRAINTS.MIN_SCALE, domainSpan / (minZoomSpan ?? domainSpan)), ZOOM_CONSTRAINTS.MIN_SCALE,
minimumSpan > 0 ? domainSpan / minimumSpan : ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE,
) )
} }
const canZoomTrack = (track: TrackLayout): boolean => const constrainTransform = (
hasMinimumVisibleXValues( 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.seriesList,
track.xScale.domain() as [number, number], track.xScale.domain() as [number, number],
Number(props.minVisiblePoints), Number.isFinite(minimum) && minimum > 0 ? Math.ceil(minimum) + 1 : minimum,
) )
}
const canZoomSharedTracks = (): boolean => { const canZoomSharedTracks = (): boolean => {
const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries) const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
return tracks.length > 0 && tracks.every(canZoomTrack) return tracks.length > 0 && tracks.every(canZoomTrack)
@@ -265,9 +285,15 @@ export function useWaveformZoom(context: ZoomContext) {
) )
if (!overlay) return if (!overlay) return
const dataDomain = resolveInitialTrackDomain(track) const dataDomain = resolveInitialTrackDomain(track)
const groups = [track.seriesList]
const behavior = zoom<SVGRectElement, unknown>() const behavior = zoom<SVGRectElement, unknown>()
.filter((event) => canHandleWheelZoom(event, canZoomTrack(track))) .filter((event) => {
.scaleExtent([1, resolveMaximumZoomScale(dataDomain)]) 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([ .extent([
[0, 0], [0, 0],
[track.width, track.height], [track.width, track.height],
@@ -293,9 +319,15 @@ export function useWaveformZoom(context: ZoomContext) {
} }
const overlay = sharedOverlayElement.value const overlay = sharedOverlayElement.value
if (!overlay) return if (!overlay) return
const groups = trackLayouts.value
.filter((track) => track.hasVisibleSeries)
.map((track) => track.seriesList)
const behavior = zoom<SVGRectElement, unknown>() const behavior = zoom<SVGRectElement, unknown>()
.filter((event) => canHandleWheelZoom(event, canZoomSharedTracks())) .filter((event) => canHandleWheelZoom(event, canZoomSharedTracks()))
.scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value)]) .scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value, groups)])
.constrain((transform) =>
constrainTransform(transform, initialXDomain.value, innerWidth.value, groups),
)
.extent([ .extent([
[0, 0], [0, 0],
[innerWidth.value, innerHeight.value], [innerWidth.value, innerHeight.value],

View 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])
})
})

View 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
}

View 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)
}

View File

@@ -9,6 +9,7 @@ export type {
WaveformDisplayMode, WaveformDisplayMode,
WaveformOverlayMode, WaveformOverlayMode,
WaveformInteractionMode, WaveformInteractionMode,
WaveformXDomainStrategy,
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,

View File

@@ -1,10 +1,18 @@
import { flushPromises } from '@vue/test-utils' import { DOMWrapper, flushPromises } from '@vue/test-utils'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { flushAnimationFrames } from '../../test/setup' import { flushAnimationFrames } from '../../test/setup'
import { mountSizedChart } from '../../test/waveformChart' import { mountSizedChart } from '../../test/waveformChart'
function getAnnotationEditor() {
const editor = Array.from(document.body.querySelectorAll<HTMLElement>('[role="dialog"]'))
.filter((element) => element.closest('.waveform-annotation-editor'))
.at(-1)
if (!editor) throw new Error('Expected annotation editor modal to be mounted')
return new DOMWrapper(editor)
}
describe('WaveformChart', () => { describe('WaveformChart', () => {
it('edits and immediately deletes existing annotations without mutating props', async () => { it('edits and immediately deletes existing annotations without mutating props', async () => {
const sourceAnnotation = { const sourceAnnotation = {
@@ -31,8 +39,10 @@ describe('WaveformChart', () => {
}) })
await wrapper.get('.waveform-annotation-context-menu button').trigger('click') await wrapper.get('.waveform-annotation-context-menu button').trigger('click')
await flushPromises() await flushPromises()
await wrapper.get('textarea[aria-label="标注文本"]').setValue('新文字') const editor = getAnnotationEditor()
await wrapper.get('.waveform-annotation-editor button.is-primary').trigger('click') await editor.get('textarea[aria-label="标注文本"]').setValue('新文字')
await editor.get('button.ant-btn-primary').trigger('click')
await flushPromises()
const updated = wrapper.emitted('update:annotations')?.at(-1)?.[0] as const updated = wrapper.emitted('update:annotations')?.at(-1)?.[0] as
Array<{ text: string }> | undefined Array<{ text: string }> | undefined

View File

@@ -179,10 +179,10 @@ describe('WaveformChart', () => {
await flushPromises() await flushPromises()
const tooltip = wrapper.get('.waveform-chart__tooltip') const tooltip = wrapper.get('.waveform-chart__tooltip')
expect(tooltip.text()).toContain('BT2_2M:') expect(tooltip.text()).toContain('未配置炮号: BT2_2M')
expect(tooltip.text()).toContain('2 T') expect(tooltip.text()).toContain('(x:1,000 y:2)')
expect(tooltip.text()).toContain('BT1_2M:') expect(tooltip.text()).toContain('未配置炮号: BT1_2M')
expect(tooltip.text()).toContain('4 T') expect(tooltip.text()).toContain('(x:1,000 y:4)')
const crosshairLines = wrapper.findAll('.waveform-chart__crosshair line') const crosshairLines = wrapper.findAll('.waveform-chart__crosshair line')
expect(crosshairLines).toHaveLength(2) expect(crosshairLines).toHaveLength(2)
crosshairLines.forEach((line) => { crosshairLines.forEach((line) => {

View File

@@ -178,7 +178,7 @@ describe('WaveformChart', () => {
await flushPromises() await flushPromises()
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1, y: 5 }]) expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([{ x: 1, y: 5 }])
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true) expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
expect(wrapper.get('.waveform-chart__tooltip-time').text()).toBe('ms: 1,000') expect(wrapper.get('.waveform-tooltip__series-label').text()).toContain('未配置炮号:')
const crosshairLines = wrapper.findAll('.waveform-chart__crosshair line') const crosshairLines = wrapper.findAll('.waveform-chart__crosshair line')
expect(crosshairLines).toHaveLength(1) expect(crosshairLines).toHaveLength(1)
expect(crosshairLines[0].attributes('x1')).toBe(crosshairLines[0].attributes('x2')) expect(crosshairLines[0].attributes('x1')).toBe(crosshairLines[0].attributes('x2'))
@@ -189,6 +189,46 @@ describe('WaveformChart', () => {
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null]) expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null])
}) })
it('clears hover when the pointer leaves the chart and restores it only after a new plot move', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 5 },
],
},
{ grid: { rowCount: 1, columnCount: 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 }),
})
overlay.element.dispatchEvent(
new MouseEvent('pointermove', { clientX: overlayWidth, clientY: 120, bubbles: true }),
)
flushAnimationFrames()
await flushPromises()
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
await wrapper.trigger('pointerleave')
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(false)
expect(wrapper.find('.waveform-chart__crosshair').exists()).toBe(false)
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null])
await wrapper.trigger('pointerenter')
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(false)
overlay.element.dispatchEvent(
new MouseEvent('pointermove', { clientX: 0, clientY: 120, bubbles: true }),
)
flushAnimationFrames()
await flushPromises()
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(true)
})
it('hides the numeric tooltip and crosshair when showTooltip is disabled', async () => { it('hides the numeric tooltip and crosshair when showTooltip is disabled', async () => {
const wrapper = await mountSizedChart( const wrapper = await mountSizedChart(
{ {
@@ -303,7 +343,7 @@ describe('WaveformChart', () => {
flushAnimationFrames() flushAnimationFrames()
await flushPromises() await flushPromises()
expect(wrapper.get('.waveform-chart__tooltip-time').text()).toBe('ms: 1,000') expect(wrapper.get('.waveform-tooltip__series-label').text()).toContain('未配置炮号:')
expect(wrapper.findAll('.waveform-chart__crosshair line')).toHaveLength(1) expect(wrapper.findAll('.waveform-chart__crosshair line')).toHaveLength(1)
expect(wrapper.get('.waveform-chart__line').element).toBe(pathBeforeHover) expect(wrapper.get('.waveform-chart__line').element).toBe(pathBeforeHover)
expect(chartUpdate).not.toHaveBeenCalled() expect(chartUpdate).not.toHaveBeenCalled()

View File

@@ -1,10 +1,18 @@
import { flushPromises } from '@vue/test-utils' import { DOMWrapper, flushPromises } from '@vue/test-utils'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { flushAnimationFrames, pendingAnimationFrameCount } from '../../test/setup' import { flushAnimationFrames, pendingAnimationFrameCount } from '../../test/setup'
import { mountSizedChart } from '../../test/waveformChart' import { mountSizedChart } from '../../test/waveformChart'
function getAnnotationEditor() {
const editor = Array.from(document.body.querySelectorAll<HTMLElement>('[role="dialog"]'))
.filter((element) => element.closest('.waveform-annotation-editor'))
.at(-1)
if (!editor) throw new Error('Expected annotation editor modal to be mounted')
return new DOMWrapper(editor)
}
describe('WaveformChart', () => { describe('WaveformChart', () => {
it('zooms only the active independent track and resets when the mode changes', async () => { it('zooms only the active independent track and resets when the mode changes', async () => {
const wrapper = await mountSizedChart({ const wrapper = await mountSizedChart({
@@ -172,9 +180,10 @@ describe('WaveformChart', () => {
) )
await flushPromises() await flushPromises()
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(true) const editor = getAnnotationEditor()
await wrapper.get('textarea[aria-label="标注文本"]').setValue('峰值点') await editor.get('textarea[aria-label="标注文本"]').setValue('峰值点')
await wrapper.get('.waveform-annotation-editor button.is-primary').trigger('click') await editor.get('button.ant-btn-primary').trigger('click')
await flushPromises()
const annotations = wrapper.emitted('update:annotations')?.at(-1)?.[0] as const annotations = wrapper.emitted('update:annotations')?.at(-1)?.[0] as
Array<{ seriesId: string; x: number; y: number; text: string }> | undefined Array<{ seriesId: string; x: number; y: number; text: string }> | undefined
@@ -209,10 +218,10 @@ describe('WaveformChart', () => {
}), }),
) )
await flushPromises() await flushPromises()
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(true) const editor = getAnnotationEditor()
expect(wrapper.get('.waveform-annotation-editor').attributes('aria-modal')).toBe('true') expect(editor.attributes('role')).toBe('dialog')
expect(wrapper.find('.waveform-annotation-editor__panel').exists()).toBe(true) expect(editor.find('.waveform-annotation-editor__content').exists()).toBe(true)
const textarea = wrapper.get('textarea[aria-label="标注文本"]') const textarea = editor.get('textarea[aria-label="标注文本"]')
const textareaContextMenu = new MouseEvent('contextmenu', { const textareaContextMenu = new MouseEvent('contextmenu', {
bubbles: true, bubbles: true,
cancelable: true, cancelable: true,
@@ -220,7 +229,8 @@ describe('WaveformChart', () => {
expect(textarea.element.dispatchEvent(textareaContextMenu)).toBe(true) expect(textarea.element.dispatchEvent(textareaContextMenu)).toBe(true)
expect(textareaContextMenu.defaultPrevented).toBe(false) expect(textareaContextMenu.defaultPrevented).toBe(false)
await textarea.setValue('右键标注') await textarea.setValue('右键标注')
await wrapper.get('.waveform-annotation-editor button.is-primary').trigger('click') await editor.get('button.ant-btn-primary').trigger('click')
await flushPromises()
// Annotation snaps to nearest sample point (x=1, y=5) // Annotation snaps to nearest sample point (x=1, y=5)
expect(wrapper.emitted('update:annotations')?.at(-1)?.[0]).toMatchObject([ expect(wrapper.emitted('update:annotations')?.at(-1)?.[0]).toMatchObject([
@@ -278,20 +288,20 @@ describe('WaveformChart', () => {
) )
await flushPromises() await flushPromises()
expect( expect(
wrapper getAnnotationEditor()
.find('select[aria-label="选择标注波形"]') .get('select[aria-label="选择标注波形"]')
.findAll('option') .findAll('option')
.map((item) => item.text()), .map((item) => item.text()),
).toEqual(['通道 A']) ).toEqual(['通道 A'])
await wrapper.get('button[aria-label="关闭标注编辑器"]').trigger('click') await getAnnotationEditor().get('.ant-modal-close').trigger('click')
overlays[0].element.dispatchEvent( overlays[0].element.dispatchEvent(
new MouseEvent('contextmenu', { clientX: 356, clientY: 230, bubbles: true }), new MouseEvent('contextmenu', { clientX: 356, clientY: 230, bubbles: true }),
) )
await flushPromises() await flushPromises()
expect( expect(
wrapper getAnnotationEditor()
.find('select[aria-label="选择标注波形"]') .get('select[aria-label="选择标注波形"]')
.findAll('option') .findAll('option')
.map((item) => item.text()), .map((item) => item.text()),
).toEqual(['通道 B']) ).toEqual(['通道 B'])

View File

@@ -7,14 +7,39 @@ import WaveformChartView from '../WaveformChartView.vue'
import { gridSeries, mountSizedChart, visibilitySeries } from '../../test/waveformChart' import { gridSeries, mountSizedChart, visibilitySeries } from '../../test/waveformChart'
function annotationEditorExists() {
return Boolean(
Array.from(document.body.querySelectorAll<HTMLElement>('[role="dialog"]'))
.filter((element) => element.closest('.waveform-annotation-editor'))
.at(-1),
)
}
describe('WaveformChart', () => { describe('WaveformChart', () => {
it('shows legends when a track contains at least two series', async () => {
const oneSeries = visibilitySeries()
if (oneSeries.kind === 'series') oneSeries.series = oneSeries.series.slice(0, 1)
const withoutLegend = await mountSizedChart(oneSeries, {
grid: { rowCount: 1, columnCount: 1 },
})
expect(withoutLegend.findAll('.waveform-chart__legend')).toHaveLength(0)
const twoSeries = visibilitySeries()
if (twoSeries.kind === 'series') twoSeries.series = twoSeries.series.slice(0, 2)
const withLegend = await mountSizedChart(twoSeries, {
grid: { rowCount: 1, columnCount: 1 },
})
expect(withLegend.findAll('.waveform-chart__legend')).toHaveLength(1)
expect(withLegend.find('.waveform-chart__legend').attributes('data-position')).toBe('top-right')
})
it('resolves legend positions by stable track id across pages', async () => { it('resolves legend positions by stable track id across pages', async () => {
const wrapper = await mountSizedChart( const wrapper = await mountSizedChart(
{ {
kind: 'series', kind: 'series',
series: Array.from({ length: 8 }, (_, index) => ({ series: Array.from({ length: 12 }, (_, index) => ({
id: `series-${index}`, id: `series-${index}`,
trackId: `frame-${Math.floor(index / 2)}`, trackId: `frame-${Math.floor(index / 3)}`,
name: `series ${index}`, name: `series ${index}`,
data: { data: {
kind: 'points' as const, kind: 'points' as const,
@@ -67,9 +92,9 @@ describe('WaveformChart', () => {
const wrapper = await mountSizedChart( const wrapper = await mountSizedChart(
{ {
kind: 'series', kind: 'series',
series: Array.from({ length: 4 }, (_, index) => ({ series: Array.from({ length: 6 }, (_, index) => ({
id: `series-${index}`, id: `series-${index}`,
trackId: `frame-${Math.floor(index / 2)}`, trackId: `frame-${Math.floor(index / 3)}`,
name: `series ${index}`, name: `series ${index}`,
data: { data: {
kind: 'points', kind: 'points',
@@ -104,7 +129,7 @@ describe('WaveformChart', () => {
}) })
const items = wrapper.findAll('.waveform-chart__legend-item') const items = wrapper.findAll('.waveform-chart__legend-item')
expect(items).toHaveLength(2) expect(items).toHaveLength(3)
expect(items.every((item) => item.attributes('disabled') !== undefined)).toBe(true) expect(items.every((item) => item.attributes('disabled') !== undefined)).toBe(true)
expect(wrapper.get('.waveform-legend__panel').classes()).not.toContain( expect(wrapper.get('.waveform-legend__panel').classes()).not.toContain(
'waveform-legend__panel--interactive', 'waveform-legend__panel--interactive',
@@ -120,7 +145,7 @@ describe('WaveformChart', () => {
overlayMode: 'multi-axis', overlayMode: 'multi-axis',
}) })
expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(2) expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(3)
expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true) expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true)
const annotationLayer = wrapper.get('.waveform-annotation-layer').element const annotationLayer = wrapper.get('.waveform-annotation-layer').element
const legendLayer = wrapper.get('.waveform-chart__legend-layer').element const legendLayer = wrapper.get('.waveform-chart__legend-layer').element
@@ -135,8 +160,8 @@ describe('WaveformChart', () => {
expect( expect(
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['low']) ).toEqual(['low', 'mid'])
expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(1) expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(2)
expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(false) expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(false)
expect(wrapper.findAll('.waveform-chart__legend-item')[1].classes()).toContain('is-hidden') expect(wrapper.findAll('.waveform-chart__legend-item')[1].classes()).toContain('is-hidden')
expect(wrapper.findAll('.waveform-chart__legend-item')[1].attributes('aria-pressed')).toBe( expect(wrapper.findAll('.waveform-chart__legend-item')[1].attributes('aria-pressed')).toBe(
@@ -162,12 +187,12 @@ describe('WaveformChart', () => {
) )
flushAnimationFrames() flushAnimationFrames()
await flushPromises() await flushPromises()
expect(wrapper.findAll('.waveform-chart__tooltip-series')).toHaveLength(1) expect(wrapper.findAll('.waveform-chart__tooltip-series')).toHaveLength(2)
expect(wrapper.get('.waveform-chart__tooltip-series').text()).toContain('低量程') expect(wrapper.get('.waveform-chart__tooltip-series').text()).toContain('低量程')
await wrapper.findAll('.waveform-chart__legend-item')[1].trigger('click') await wrapper.findAll('.waveform-chart__legend-item')[1].trigger('click')
await flushPromises() await flushPromises()
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(2) expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(3)
expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true) expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true)
expect(wrapper.emitted('series-visibility-change')?.at(-1)).toEqual([ expect(wrapper.emitted('series-visibility-change')?.at(-1)).toEqual([
{ seriesId: 'high', visible: true, hiddenSeriesIds: [] }, { seriesId: 'high', visible: true, hiddenSeriesIds: [] },
@@ -183,20 +208,20 @@ describe('WaveformChart', () => {
expect( expect(
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['low']) ).toEqual(['low', 'mid'])
await wrapper.findAll('.waveform-chart__legend-item')[0].trigger('click') await wrapper.findAll('.waveform-chart__legend-item')[0].trigger('click')
expect(wrapper.emitted('update:hidden-series-ids')?.at(-1)).toEqual([ expect(wrapper.emitted('update:hidden-series-ids')?.at(-1)).toEqual([
['high', 'temporarily-absent', 'low'], ['high', 'temporarily-absent', 'low'],
]) ])
expect( expect(
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['low']) ).toEqual(['low', 'mid'])
await wrapper.setProps({ hiddenSeriesIds: ['low'] }) await wrapper.setProps({ hiddenSeriesIds: ['low'] })
await flushPromises() await flushPromises()
expect( expect(
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['high']) ).toEqual(['high', 'mid'])
}) })
it('retains uncontrolled visibility by stable ID and clears removed IDs', async () => { it('retains uncontrolled visibility by stable ID and clears removed IDs', async () => {
@@ -215,7 +240,7 @@ describe('WaveformChart', () => {
await flushPromises() await flushPromises()
expect( expect(
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['low']) ).toEqual(['mid', 'low'])
await wrapper.setProps({ await wrapper.setProps({
data: { data: {
@@ -226,12 +251,12 @@ describe('WaveformChart', () => {
await flushPromises() await flushPromises()
await wrapper.setProps({ data: original }) await wrapper.setProps({ data: original })
await flushPromises() await flushPromises()
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(2) expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(3)
}) })
it('keeps a recoverable legend and stops chart interaction when every series is hidden', async () => { it('keeps a recoverable legend and stops chart interaction when every series is hidden', async () => {
const wrapper = await mountSizedChart(visibilitySeries(), { const wrapper = await mountSizedChart(visibilitySeries(), {
defaultHiddenSeriesIds: ['low', 'high'], defaultHiddenSeriesIds: ['low', 'high', 'mid'],
grid: { rowCount: 1, columnCount: 1 }, grid: { rowCount: 1, columnCount: 1 },
legend: { interactive: true }, legend: { interactive: true },
overlayMode: 'multi-axis', overlayMode: 'multi-axis',
@@ -240,7 +265,7 @@ describe('WaveformChart', () => {
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(0) expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(0)
expect(wrapper.findAll('.waveform-chart__axis')).toHaveLength(0) expect(wrapper.findAll('.waveform-chart__axis')).toHaveLength(0)
expect(wrapper.findAll('.waveform-chart__overlay')).toHaveLength(0) expect(wrapper.findAll('.waveform-chart__overlay')).toHaveLength(0)
expect(wrapper.findAll('.waveform-chart__legend-item')).toHaveLength(2) expect(wrapper.findAll('.waveform-chart__legend-item')).toHaveLength(3)
expect(wrapper.get('.waveform-track__no-visible-series').text()).toBe('暂无可见曲线') expect(wrapper.get('.waveform-track__no-visible-series').text()).toBe('暂无可见曲线')
await wrapper.findAll('.waveform-chart__legend-item')[0].trigger('click') await wrapper.findAll('.waveform-chart__legend-item')[0].trigger('click')
@@ -273,7 +298,7 @@ describe('WaveformChart', () => {
) )
flushAnimationFrames() flushAnimationFrames()
await flushPromises() await flushPromises()
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(true) expect(annotationEditorExists()).toBe(true)
const controller = wrapper.getComponent(WaveformChartView).props('controller') as unknown as { const controller = wrapper.getComponent(WaveformChartView).props('controller') as unknown as {
annotationInteraction: { editorDraft: { value: { annotation: { seriesId: string } } | null } } annotationInteraction: { editorDraft: { value: { annotation: { seriesId: string } } | null } }
@@ -287,7 +312,7 @@ describe('WaveformChart', () => {
expect(item).toBeDefined() expect(item).toBeDefined()
await item!.trigger('click') await item!.trigger('click')
await flushPromises() await flushPromises()
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(false) expect(annotationEditorExists()).toBe(false)
}) })
it('renders independent cells with separate x axes and overlays', async () => { it('renders independent cells with separate x axes and overlays', async () => {

View File

@@ -177,6 +177,18 @@ describe('WaveformChart', () => {
], ],
}, },
}, },
{
id: 'reference',
trackId: 'frame-1',
name: 'REF_CH_1',
data: {
kind: 'points',
points: [
{ x: 0, y: 0.25 },
{ x: 1, y: 1.25 },
],
},
},
], ],
}, },
{ frameNumber: 1, grid: { rowCount: 2, columnCount: 1 } }, { frameNumber: 1, grid: { rowCount: 2, columnCount: 1 } },
@@ -186,7 +198,7 @@ describe('WaveformChart', () => {
expect(tracks).toHaveLength(2) expect(tracks).toHaveLength(2)
expect( expect(
tracks[0].findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), tracks[0].findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['primary', 'comparison']) ).toEqual(['primary', 'comparison', 'reference'])
expect( expect(
tracks[1].findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')), tracks[1].findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['second-frame']) ).toEqual(['second-frame'])
@@ -203,12 +215,13 @@ describe('WaveformChart', () => {
expect(legend.findAll('.waveform-chart__legend-item').map((item) => item.text())).toEqual([ expect(legend.findAll('.waveform-chart__legend-item').map((item) => item.text())).toEqual([
'BT2_2M', 'BT2_2M',
'TEST_CH_1', 'TEST_CH_1',
'REF_CH_1',
]) ])
expect( expect(
legend legend
.findAll('.waveform-legend__swatch') .findAll('.waveform-legend__swatch')
.map((swatch) => swatch.get('path').attributes('stroke')), .map((swatch) => swatch.get('path').attributes('stroke')),
).toEqual(['#0960bd', '#389e0d']) ).toEqual(['#0960bd', '#2ca02c', '#d62728'])
expect(wrapper.findAll('.waveform-chart__watermark').map((item) => item.text())).toEqual([ expect(wrapper.findAll('.waveform-chart__watermark').map((item) => item.text())).toEqual([
'1', '1',
'2', '2',
@@ -232,10 +245,11 @@ describe('WaveformChart', () => {
await flushPromises() await flushPromises()
const tooltipSeries = wrapper.findAll('.waveform-chart__tooltip-series') const tooltipSeries = wrapper.findAll('.waveform-chart__tooltip-series')
expect(tooltipSeries).toHaveLength(2) expect(tooltipSeries).toHaveLength(3)
expect(tooltipSeries.map((item) => item.text())).toEqual([ expect(tooltipSeries.map((item) => item.text())).toEqual([
expect.stringContaining('BT2_2M'), expect.stringContaining('BT2_2M'),
expect.stringContaining('TEST_CH_1'), expect.stringContaining('TEST_CH_1'),
expect.stringContaining('REF_CH_1'),
]) ])
}) })
@@ -268,6 +282,18 @@ describe('WaveformChart', () => {
], ],
}, },
}, },
{
id: 'third',
trackId: 'shared',
name: 'third',
data: {
kind: 'points',
points: [
{ x: 0, y: 2 },
{ x: 1, y: 3 },
],
},
},
], ],
}, },
{ grid: { rowCount: 1, columnCount: 1 } }, { grid: { rowCount: 1, columnCount: 1 } },

View File

@@ -5,6 +5,14 @@ import { flushAnimationFrames } from '../../test/setup'
import { mountSizedChart } from '../../test/waveformChart' import { mountSizedChart } from '../../test/waveformChart'
import type { WaveformData, WaveformDisplayMode } from '../data/types' import type { WaveformData, WaveformDisplayMode } from '../data/types'
function annotationEditorExists() {
return Boolean(
Array.from(document.body.querySelectorAll<HTMLElement>('[role="dialog"]'))
.filter((element) => element.closest('.waveform-annotation-editor'))
.at(-1),
)
}
const presentationData: WaveformData = { const presentationData: WaveformData = {
kind: 'series', kind: 'series',
series: [ series: [
@@ -34,6 +42,19 @@ const presentationData: WaveformData = {
], ],
}, },
}, },
{
id: 'mid',
trackId: 'shared',
name: '中量程',
data: {
kind: 'points',
points: [
{ x: 0, y: 50 },
{ x: 1, y: 100 },
{ x: 2, y: 75 },
],
},
},
{ {
id: 'other', id: 'other',
name: '其他通道', name: '其他通道',
@@ -142,9 +163,9 @@ describe('WaveformChart presentation mode', () => {
cancelable: true, cancelable: true,
}), }),
) )
wrapper.get('.waveform-chart__svg').element.dispatchEvent( wrapper
new MouseEvent('dblclick', { bubbles: true, cancelable: true }), .get('.waveform-chart__svg')
) .element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
await wrapper.get('[data-annotation-id="note"]').trigger('contextmenu', { await wrapper.get('[data-annotation-id="note"]').trigger('contextmenu', {
clientX: width / 2, clientX: width / 2,
clientY: height / 2, clientY: height / 2,
@@ -282,13 +303,13 @@ describe('WaveformChart presentation mode', () => {
}), }),
) )
await flushPromises() await flushPromises()
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(true) expect(annotationEditorExists()).toBe(true)
await wrapper.setProps({ presentationMode: true }) await wrapper.setProps({ presentationMode: true })
await flushPromises() await flushPromises()
expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(false) expect(wrapper.find('.waveform-chart__tooltip').exists()).toBe(false)
expect(wrapper.find('.waveform-annotation-editor').exists()).toBe(false) expect(annotationEditorExists()).toBe(false)
expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null]) expect(wrapper.emitted('point-hover')?.at(-1)).toEqual([null])
await wrapper.setProps({ presentationMode: false }) await wrapper.setProps({ presentationMode: false })

View File

@@ -181,7 +181,7 @@ describe('WaveformChart', () => {
]) ])
expect(stepSwatchPaths[0]?.attributes()).toMatchObject({ expect(stepSwatchPaths[0]?.attributes()).toMatchObject({
d: 'M1 8H25', d: 'M1 8H25',
stroke: '#389e0d', stroke: '#2ca02c',
'stroke-width': '1.5', 'stroke-width': '1.5',
}) })
expect(stepSwatchPaths[1]?.attributes()).toMatchObject({ expect(stepSwatchPaths[1]?.attributes()).toMatchObject({

View File

@@ -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')
})
}) })

View 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)
})
})

View 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')
})
})

View File

@@ -0,0 +1,156 @@
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')
})
it('keeps an explicit viewport wider than the data and leaves uncovered time blank', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 10, y: 1 },
],
},
{ timeUnit: 's', initialXDomain: [-5, 20] },
)
const track = wrapper.get('.waveform-chart__track')
const width = Number(track.attributes('data-track-width'))
const path = track.get('.waveform-chart__line').attributes('d') ?? ''
const xCoordinates = Array.from(path.matchAll(/(?:M|L)([\d.-]+)/g)).map((match) =>
Number(match[1]),
)
expect(track.get('.waveform-chart__axis-endpoint--start').text()).toBe('-5')
expect(track.get('.waveform-chart__axis-endpoint--end').text()).toBe('20')
expect(xCoordinates.length).toBeGreaterThan(0)
expect(Math.min(...xCoordinates)).toBeGreaterThanOrEqual(0)
expect(Math.max(...xCoordinates)).toBeLessThanOrEqual(width)
})
it('applies independent explicit viewports per track, including empty time regions', async () => {
const wrapper = await mountSizedChart(
{
kind: 'series',
series: [
{
id: 'first',
name: '第一轨',
data: { kind: 'points', points: [{ x: 0, y: 0 }, { x: 10, y: 1 }] },
},
{
id: 'second',
name: '第二轨',
data: { kind: 'points', points: [{ x: 100, y: 0 }, { x: 110, y: 1 }] },
},
],
},
{
displayMode: 'independent',
grid: { rowCount: 1, columnCount: 2 },
timeUnit: 's',
initialXDomains: { first: [-5, 20], second: [95, 120] },
},
)
const tracks = wrapper.findAll('.waveform-chart__track')
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('-5')
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('20')
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('95')
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('120')
expect(tracks[0]?.find('.waveform-chart__line').exists()).toBe(true)
expect(tracks[1]?.find('.waveform-chart__line').exists()).toBe(true)
})
})

View 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])
})
})

View File

@@ -102,4 +102,26 @@ describe('waveform data normalization', () => {
'solid', 'solid',
]) ])
}) })
it('preserves trimmed shot numbers and omits blank values', () => {
const result = normalizeWaveformSeries({
kind: 'series',
series: [
{
id: 'with-shot',
shotNo: ' 13300 ',
name: '通道 A',
data: { kind: 'points', points: [{ x: 0, y: 1 }] },
},
{
id: 'without-shot',
shotNo: ' ',
name: '通道 B',
data: { kind: 'points', points: [{ x: 0, y: 2 }] },
},
],
})
expect(result.map((series) => series.shotNo)).toEqual(['13300', undefined])
})
}) })

View File

@@ -132,6 +132,7 @@ export function normalizeWaveformSeries(data: WaveformData): NormalizedWaveformS
return { return {
id: uniqueId, id: uniqueId,
shotNo: series.shotNo?.trim() || undefined,
trackId: series.trackId?.trim() || undefined, trackId: series.trackId?.trim() || undefined,
name: series.name, name: series.name,
unit: series.unit, unit: series.unit,

View File

@@ -3,6 +3,10 @@ import { bisector } from 'd3'
import type { WaveformPoint } from '@/types' import type { WaveformPoint } from '@/types'
import { resolveWaveformPointErrors } from './data' import { resolveWaveformPointErrors } from './data'
import type { ResolvedWaveformRenderingOptions } from './renderingOptions' import type { ResolvedWaveformRenderingOptions } from './renderingOptions'
import {
resolveRenderablePointSelectionStrategy,
type VisiblePointRange,
} from './renderingStrategies'
export { export {
DEFAULT_WAVEFORM_RENDERING_OPTIONS, DEFAULT_WAVEFORM_RENDERING_OPTIONS,
@@ -13,11 +17,6 @@ export {
const pointBisector = bisector((point: WaveformPoint) => point.x) const pointBisector = bisector((point: WaveformPoint) => point.x)
const acceptAllPoints = () => true const acceptAllPoints = () => true
interface VisiblePointRange {
start: number
end: number
}
interface PointSeriesSource { interface PointSeriesSource {
points: WaveformPoint[] points: WaveformPoint[]
} }
@@ -65,10 +64,6 @@ export function hasMinimumVisibleXValues(
return false return false
} }
function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefined) {
if (point && target[target.length - 1] !== point) target.push(point)
}
function selectRenderablePointsInRange( function selectRenderablePointsInRange(
points: WaveformPoint[], points: WaveformPoint[],
range: VisiblePointRange, range: VisiblePointRange,
@@ -76,82 +71,17 @@ function selectRenderablePointsInRange(
width: number, width: number,
options: ResolvedWaveformRenderingOptions, options: ResolvedWaveformRenderingOptions,
): WaveformPoint[] { ): WaveformPoint[] {
const domainStart = Math.min(domain[0], domain[1])
const domainEnd = Math.max(domain[0], domain[1])
const start = Math.max(0, range.start - 1) const start = Math.max(0, range.start - 1)
const end = Math.min(points.length, range.end + 1) const end = Math.min(points.length, range.end + 1)
const visibleCount = end - start const visibleCount = end - start
if (visibleCount <= 0) return [] if (visibleCount <= 0) return []
if (!options.downsample || visibleCount <= options.downsampleThreshold) { return resolveRenderablePointSelectionStrategy({ visibleCount, width, options })({
return points.slice(start, end) points,
} range,
domain,
const maximumPointCount = Math.max(4, Math.floor(width * options.maxPointsPerPixel)) width,
const bucketCount = Math.max(1, Math.floor(maximumPointCount / 4)) options,
if (visibleCount <= maximumPointCount) return points.slice(start, end) })
const result: WaveformPoint[] = []
const span = domainEnd - domainStart || 1
const bucketIndexes = Array.from({ length: 4 }, () => -1)
let activeBucket = -1
let firstIndex = -1
let lastIndex = -1
let minimumIndex = -1
let maximumIndex = -1
const addBucketIndex = (index: number, count: number) => {
if (index < 0) return count
for (let position = 0; position < count; position += 1) {
if (bucketIndexes[position] === index) return count
}
bucketIndexes[count] = index
return count + 1
}
const flushBucket = () => {
if (firstIndex < 0) return
let count = 0
count = addBucketIndex(firstIndex, count)
count = addBucketIndex(minimumIndex, count)
count = addBucketIndex(maximumIndex, count)
count = addBucketIndex(lastIndex, count)
for (let index = 1; index < count; index += 1) {
const value = bucketIndexes[index]
let position = index - 1
while (position >= 0 && bucketIndexes[position] > value) {
bucketIndexes[position + 1] = bucketIndexes[position]
position -= 1
}
bucketIndexes[position + 1] = value
}
for (let index = 0; index < count; index += 1) {
pushUniquePoint(result, points[bucketIndexes[index]])
}
}
pushUniquePoint(result, points[start])
for (let index = range.start; index < range.end; index += 1) {
const point = points[index]
const bucket = Math.min(
bucketCount - 1,
Math.max(0, Math.floor(((point.x - domainStart) / span) * bucketCount)),
)
if (bucket !== activeBucket) {
flushBucket()
activeBucket = bucket
firstIndex = index
lastIndex = index
minimumIndex = index
maximumIndex = index
continue
}
lastIndex = index
if (point.y < points[minimumIndex].y) minimumIndex = index
if (point.y > points[maximumIndex].y) maximumIndex = index
}
flushBucket()
pushUniquePoint(result, points[end - 1])
return result
} }
/** /**

View File

@@ -0,0 +1,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]))
})
})

View 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
}

View File

@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import { createSimulatedWaveformData } from './simulatedWaveforms' import { createSimulatedWaveformData } from './simulatedWaveforms'
describe('simulated waveform data', () => { describe('simulated waveform data', () => {
it('creates deterministic, finite six-channel data', () => { it('creates deterministic, finite seven-channel data with a two-series second frame', () => {
const first = createSimulatedWaveformData() const first = createSimulatedWaveformData()
const second = createSimulatedWaveformData() const second = createSimulatedWaveformData()
@@ -11,8 +11,13 @@ describe('simulated waveform data', () => {
expect(first.kind).toBe('series') expect(first.kind).toBe('series')
if (first.kind !== 'series') return if (first.kind !== 'series') return
expect(first.series).toHaveLength(6) expect(first.series).toHaveLength(7)
expect(new Set(first.series.map((series) => series.id)).size).toBe(6) expect(new Set(first.series.map((series) => series.id)).size).toBe(7)
const secondFrame = first.series.filter(
(series) => series.trackId === 'simulated-harmonic-frame',
)
expect(secondFrame).toHaveLength(2)
expect(new Set(first.series.map((series) => series.shotNo))).toEqual(new Set(['13300']))
first.series.forEach((series) => { first.series.forEach((series) => {
expect(series.data.kind).toBe('points') expect(series.data.kind).toBe('points')
if (series.data.kind !== 'points') return if (series.data.kind !== 'points') return

View File

@@ -6,16 +6,15 @@ const END_TIME = 5
const TWO_PI = Math.PI * 2 const TWO_PI = Math.PI * 2
type SignalGenerator = (time: number, noise: number) => number type SignalGenerator = (time: number, noise: number) => number
type ErrorGenerator = (time: number, value: number) => Pick< type ErrorGenerator = (
WaveformPoint, time: number,
'error' | 'lowerError' | 'upperError' value: number,
> ) => Pick<WaveformPoint, 'error' | 'lowerError' | 'upperError'>
interface SimulatedSeriesDefinition interface SimulatedSeriesDefinition extends Pick<
extends Pick< WaveformSeries,
WaveformSeries, 'id' | 'trackId' | 'shotNo' | 'name' | 'unit' | 'color' | 'lineType' | 'pointType' | 'errorBar'
'id' | 'name' | 'unit' | 'lineType' | 'pointType' | 'errorBar' > {
> {
signal: SignalGenerator signal: SignalGenerator
errors?: ErrorGenerator errors?: ErrorGenerator
} }
@@ -39,7 +38,7 @@ function createPoints(
return { return {
x: time, x: time,
y: value, y: value,
...(errors?.(time, value) ?? {}), ...errors?.(time, value),
} }
}) })
} }
@@ -47,6 +46,7 @@ function createPoints(
const seriesDefinitions: SimulatedSeriesDefinition[] = [ const seriesDefinitions: SimulatedSeriesDefinition[] = [
{ {
id: 'simulated-sine', id: 'simulated-sine',
shotNo: '13300',
name: '正弦基波', name: '正弦基波',
unit: 'V', unit: 'V',
lineType: 'none', lineType: 'none',
@@ -60,6 +60,8 @@ const seriesDefinitions: SimulatedSeriesDefinition[] = [
}, },
{ {
id: 'simulated-harmonic', id: 'simulated-harmonic',
trackId: 'simulated-harmonic-frame',
shotNo: '13300',
name: '谐波扰动', name: '谐波扰动',
unit: 'V', unit: 'V',
lineType: 'linear', lineType: 'linear',
@@ -67,8 +69,21 @@ const seriesDefinitions: SimulatedSeriesDefinition[] = [
signal: (time) => signal: (time) =>
0.9 * Math.sin(TWO_PI * 0.55 * time) + 0.28 * Math.sin(TWO_PI * 2.2 * time + 0.4), 0.9 * Math.sin(TWO_PI * 0.55 * time) + 0.28 * Math.sin(TWO_PI * 2.2 * time + 0.4),
}, },
{
id: 'simulated-harmonic-reference',
trackId: 'simulated-harmonic-frame',
shotNo: '13300',
name: '谐波对比',
unit: 'V',
color: '#2ca02c',
lineType: 'linear',
pointType: 'none',
signal: (time) =>
0.62 * Math.sin(TWO_PI * 0.55 * time + 0.55) + 0.18 * Math.sin(TWO_PI * 2.2 * time - 0.2),
},
{ {
id: 'simulated-damped', id: 'simulated-damped',
shotNo: '13300',
name: '阻尼振荡', name: '阻尼振荡',
unit: 'A', unit: 'A',
lineType: 'linear', lineType: 'linear',
@@ -80,6 +95,7 @@ const seriesDefinitions: SimulatedSeriesDefinition[] = [
}, },
{ {
id: 'simulated-step', id: 'simulated-step',
shotNo: '13300',
name: '阶跃响应', name: '阶跃响应',
unit: 'V', unit: 'V',
lineType: 'linear', lineType: 'linear',
@@ -88,6 +104,7 @@ const seriesDefinitions: SimulatedSeriesDefinition[] = [
}, },
{ {
id: 'simulated-pulse', id: 'simulated-pulse',
shotNo: '13300',
name: '脉冲响应', name: '脉冲响应',
unit: 'V', unit: 'V',
lineType: 'linear', lineType: 'linear',
@@ -96,6 +113,7 @@ const seriesDefinitions: SimulatedSeriesDefinition[] = [
}, },
{ {
id: 'simulated-noise', id: 'simulated-noise',
shotNo: '13300',
name: '带噪信号', name: '带噪信号',
unit: 'A', unit: 'A',
lineType: 'linear', lineType: 'linear',

View File

@@ -22,8 +22,7 @@ defineExpose({ resetViewport })
v-model:annotations="model.annotations" v-model:annotations="model.annotations"
v-model:hidden-series-ids="model.hiddenSeriesIds" v-model:hidden-series-ids="model.hiddenSeriesIds"
:data="model.data" :data="model.data"
:min-zoom-span="model.minZoomSpan" :min-visible-points="2"
:min-visible-points="5"
:initial-x-domain="model.initialXDomain" :initial-x-domain="model.initialXDomain"
:display-mode="model.displayMode" :display-mode="model.displayMode"
:overlay-mode="model.overlayMode" :overlay-mode="model.overlayMode"

View File

@@ -86,7 +86,6 @@ export interface DemoControlPanelModel {
export interface DemoChartModel { export interface DemoChartModel {
data: WaveformData data: WaveformData
minZoomSpan?: number
initialXDomain?: [number, number] initialXDomain?: [number, number]
displayMode: WaveformDisplayMode displayMode: WaveformDisplayMode
overlayMode: WaveformOverlayMode overlayMode: WaveformOverlayMode

View File

@@ -13,7 +13,9 @@ export type {
WaveformDisplayMode, WaveformDisplayMode,
WaveformOverlayMode, WaveformOverlayMode,
WaveformInteractionMode, WaveformInteractionMode,
WaveformXDomainStrategy,
WaveformZoomEndPayload, WaveformZoomEndPayload,
WaveformZoomResetPayload,
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,

View File

@@ -58,6 +58,18 @@ export function visibilitySeries(): WaveformData {
], ],
}, },
}, },
{
id: 'mid',
trackId: 'shared-frame',
name: '中量程',
data: {
kind: 'points',
points: [
{ x: 0, y: 100 },
{ x: 1, y: 200 },
],
},
},
], ],
} }
} }

View File

@@ -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

View File

@@ -47,6 +47,8 @@ export type SingleWaveformData =
*/ */
export interface WaveformSeries { export interface WaveformSeries {
id?: string id?: string
/** 炮号,未提供时 Tooltip 显示“未配置炮号”。 */
shotNo?: string
/** 相同 trackId 的系列叠加在同一图框中;默认每个系列独占一个图框。 */ /** 相同 trackId 的系列叠加在同一图框中;默认每个系列独占一个图框。 */
trackId?: string trackId?: string
name: string name: string
@@ -74,6 +76,7 @@ export type WaveformData =
*/ */
export interface NormalizedWaveformSeries { export interface NormalizedWaveformSeries {
id: string id: string
shotNo?: string
trackId?: string trackId?: string
name: string name: string
unit?: string unit?: string

View File

@@ -8,7 +8,9 @@ export type {
WaveformDisplayMode, WaveformDisplayMode,
WaveformOverlayMode, WaveformOverlayMode,
WaveformInteractionMode, WaveformInteractionMode,
WaveformXDomainStrategy,
WaveformZoomEndPayload, WaveformZoomEndPayload,
WaveformZoomResetPayload,
WaveformAnnotationStyle, WaveformAnnotationStyle,
WaveformAnnotation, WaveformAnnotation,
WaveformRenderingOptions, WaveformRenderingOptions,

View File

@@ -40,10 +40,11 @@ describe('waveform number formatters', () => {
expect(formatScientificAxisExponent(0.0001, 0.0003)).toBe('E-04') expect(formatScientificAxisExponent(0.0001, 0.0003)).toBe('E-04')
}) })
it('derives the exponent from Math.max(axisMin, axisMax)', () => { it('derives the exponent from the largest absolute endpoint', () => {
expect(resolveScientificAxisExponent(-9000, -1000)).toBe(3) expect(resolveScientificAxisExponent(-9000, -1000)).toBe(3)
expect(resolveScientificAxisExponent(-10_000, 3000)).toBe(3) expect(resolveScientificAxisExponent(-100_000, -3000)).toBe(5)
expect(resolveScientificAxisExponent(-1000, 0)).toBeNull() expect(resolveScientificAxisExponent(-10_000, 3000)).toBe(4)
expect(resolveScientificAxisExponent(-1000, 0)).toBe(3)
expect(resolveScientificAxisExponent(0, 0)).toBeNull() expect(resolveScientificAxisExponent(0, 0)).toBeNull()
}) })

View File

@@ -42,8 +42,7 @@ export function shouldUseScientificAxisLabel(maxAbsoluteValue: number): boolean
export function resolveScientificAxisExponent(axisMin?: number, axisMax?: number): number | null { export function resolveScientificAxisExponent(axisMin?: number, axisMax?: number): number | null {
if (typeof axisMin !== 'number' || typeof axisMax !== 'number') return null if (typeof axisMin !== 'number' || typeof axisMax !== 'number') return null
const maxValue = Math.max(axisMin, axisMax) const absoluteMaxValue = Math.max(Math.abs(axisMin), Math.abs(axisMax))
const absoluteMaxValue = Math.abs(maxValue)
return shouldUseScientificAxisLabel(absoluteMaxValue) return shouldUseScientificAxisLabel(absoluteMaxValue)
? Math.floor(Math.log10(absoluteMaxValue)) ? Math.floor(Math.log10(absoluteMaxValue))
: null : null

View File

@@ -37,7 +37,8 @@ export function downsampleLTTB(data: WaveformPoint[], threshold: number): Wavefo
// 确保阈值至少为 3 // 确保阈值至少为 3
const sampledLength = Math.max(3, Math.floor(threshold)) const sampledLength = Math.max(3, Math.floor(threshold))
const sampled: WaveformPoint[] = new Array(sampledLength) const sampled: WaveformPoint[] = []
sampled.length = sampledLength
// 始终保留第一个和最后一个点 // 始终保留第一个和最后一个点
sampled[0] = data[0]! sampled[0] = data[0]!

View File

@@ -19,6 +19,7 @@ const chartData: WaveformData = {
series: [ series: [
{ {
id: 'voltage', id: 'voltage',
shotNo: '13300',
name: '电压', name: '电压',
unit: 'V', unit: 'V',
color: '#1677ff', color: '#1677ff',
@@ -33,6 +34,7 @@ const chartData: WaveformData = {
}, },
{ {
id: 'current', id: 'current',
shotNo: '13300',
name: '电流', name: '电流',
unit: 'mA', unit: 'mA',
color: '#d4380d', color: '#d4380d',