7 Commits

Author SHA1 Message Date
李启源
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
李启源
9eb1f0f137 feat(chart): support configurable plot margins
All checks were successful
Package component / package (push) Successful in 7m2s
2026-08-05 15:16:52 +08:00
李启源
fbf10fdb88 feat(chart): support configurable x-axis labels
All checks were successful
Package component / package (push) Successful in 4m35s
2026-08-05 10:58:40 +08:00
52 changed files with 2269 additions and 307 deletions

View File

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

8
.oxlintrc.json Normal file
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/**"]
}

View File

@@ -97,7 +97,7 @@ const data = ref<WaveformData>({
### Props
| Prop | 类型 | 默认值 | 说明 |
| --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | --------------------------------- |
| --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | ---------------------------------- |
| `data` | `WaveformData` | 必填 | 波形数据 |
| `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 |
| `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 |
@@ -114,7 +114,7 @@ const data = ref<WaveformData>({
| `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 |
| `yDomains` | `Record<string, [number, number]>` | 未设置 | 按 track/series ID 配置固定范围 |
| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 |
| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐 |
| `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐与 X 轴 label 格式化 |
| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 |
| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 |
| `frameNumber` | `string \| number` | 未设置 | 图框水印内容 |
@@ -129,8 +129,8 @@ const data = ref<WaveformData>({
所有公开类型均可从包入口导入,例如 `WaveformData``WaveformSeries`
`WaveformAnnotation``WaveformLineStyle``WaveformRenderingOptions`
`WaveformAxesOptions``WaveformZeroLineOptions``WaveformGridOptions`
`WaveformGridTrackLines`
`WaveformAxesOptions``WaveformXAxisLabelFormatter``WaveformZeroLineOptions`
`WaveformGridOptions``WaveformGridTrackLines`
### 数据结构
@@ -508,6 +508,30 @@ scale 定位零线:
/>
```
X 轴刻度和左右端点默认先按 `timeUnit` 转换为秒或毫秒,再显示为无千分位、无科学计数法的
完整普通十进制值,不会四舍五入为整数。可通过 `axes.x.labelFormatter` 对显示值做运算和格式化:
```vue
<WaveformChart
:data="chartData"
:axes="{
x: {
labelFormatter: (value, context) =>
`${context.kind === 'tick' ? '' : '[' + context.kind + '] '}${(value / 1000).toFixed(3)}`,
},
}"
/>
```
formatter 首参是已按 `timeUnit` 换算的数值;上下文包含 `kind``tick``start``end`)、
原始 `rawValue``timeUnit`、原始可视域 `domain` 和换算后的 `displayDomain`。formatter 只决定
label 文本,不改变刻度位置、源数据、缩放域或事件中的原始 X 坐标。
Demo 左侧“网格与轴线”支持直接切换数值或固定时间格式。数值模式可设置运算倍率和小数位数;
固定时间模式输出 `YYYY-MM-DD HH:mm:ss[.SSS]`,可选择中国标准时间、本地时区或 UTC并控制
是否显示毫秒。时间戳数据仍按组件的秒坐标契约传入,使用默认毫秒显示单位时 formatter 会收到
可直接传给 `Date` 的毫秒时间戳。
`interactionMode` 可选 `zoom``annotation`,默认使用缩放模式。右键绘图区可直接打开
标注编辑器,无需切换交互模式。`zoomable``pannable``showTooltip` 可分别控制缩放、
空格拖拽平移和 tooltip平移默认关闭。
@@ -603,7 +627,7 @@ async function importAnnotationFile(file: File) {
字段无效时会抛出 `TypeError`,不会返回部分结果。导入包含未知 `seriesId` 的标注是允许的,
对应曲线加载后会恢复显示。文件选择、错误提示和下载由业务层实现。
X 轴刻度和左右端点先按 `timeUnit` 转换为秒或毫秒,再四舍五入为不带分组符的普通整数,不使用科学计数法。Y 轴会根据完整显示域选择格式:最大绝对值在 `[0.01, 100)` 时显示两位普通小数;大于等于 `100`,或大于 `0` 且小于 `0.01` 时,刻度显示两位缩放值,并在轴末端单独显示共享倍率 `E±NN`,多 Y 轴分别计算倍率。tooltip 使用最多 4 位小数的本地化普通数字并省略无意义尾零;标注编辑器的 X 坐标跟随 `timeUnit` 并固定 3 位小数Y 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。
X 轴刻度和左右端点先按 `timeUnit` 转换为秒或毫秒,再显示为不带分组符和科学计数法的完整普通十进制值,并可通过 `axes.x.labelFormatter` 自定义。Y 轴会根据完整显示域选择格式:最大绝对值在 `[0.01, 100)` 时显示两位普通小数;大于等于 `100`,或大于 `0` 且小于 `0.01` 时,刻度显示两位缩放值,并在轴末端单独显示共享倍率 `E±NN`,多 Y 轴分别计算倍率。tooltip 使用最多 4 位小数的本地化普通数字并省略无意义尾零;标注编辑器的 X 坐标跟随 `timeUnit` 并固定 3 位小数Y 坐标显示完整普通十进制。所有格式化都只发生在展示层,内部坐标值保持原始精度。
标注框默认布局在采样点正上方,只做绘图区边界裁剪;文本框通过连接箭头指向标注位置,多个标注重叠时可通过拖动手动避让。
## 事件
@@ -644,12 +668,20 @@ pnpm dev
```bash
pnpm typecheck
pnpm lint:oxlint
pnpm lint
pnpm lint:all
pnpm format:check
pnpm test
pnpm test:coverage
pnpm build
```
`pnpm lint:oxlint` 使用 Oxlint 的默认 correctness 检查及内置 TypeScript、Unicorn 和 Oxc
插件,自动忽略 `dist/``dist-demo/``coverage/``node_modules/``pnpm lint` 继续负责
ESLint 的 Vue SFC、TypeScript ESLint 和 `max-lines` 规则;`pnpm lint:all` 会依次运行两者。
`pnpm format:check` 只读检查 Prettier 格式,`pnpm format` 保持原有的写入行为。
`pnpm build` 同时生成 `dist/` 组件库产物和 `dist-demo/` 演示应用。正式公开入口为
`src/index.ts`,样式入口为 `src/styles.css``dist/``dist-demo/` 均为生成目录,不要手工编辑。

37
docs/architecture.md Normal file
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",
"version": "0.1.28",
"version": "0.1.31",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/types/index.d.ts",
@@ -31,7 +31,10 @@
"typecheck": "vue-tsc -b",
"check:file-length": "node scripts/check-file-length.mjs",
"lint": "eslint . --max-warnings=0",
"lint:oxlint": "oxlint . --deny-warnings",
"lint:all": "pnpm lint:oxlint && pnpm lint",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run",
"test:coverage": "vitest run --coverage"
},
@@ -57,6 +60,7 @@
"eslint-plugin-vue": "10.9.2",
"globals": "17.7.0",
"jsdom": "29.1.1",
"oxlint": "1.77.0",
"prettier": "3.9.5",
"typescript": "~6.0.0",
"typescript-eslint": "8.64.0",

217
pnpm-lock.yaml generated
View File

@@ -54,6 +54,9 @@ importers:
jsdom:
specifier: 29.1.1
version: 29.1.1
oxlint:
specifier: 1.77.0
version: 1.77.0
prettier:
specifier: 3.9.5
version: 3.9.5
@@ -279,6 +282,128 @@ packages:
'@oxc-project/types@0.139.0':
resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==}
'@oxlint/binding-android-arm-eabi@1.77.0':
resolution: {integrity: sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [android]
'@oxlint/binding-android-arm64@1.77.0':
resolution: {integrity: sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [android]
'@oxlint/binding-darwin-arm64@1.77.0':
resolution: {integrity: sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [darwin]
'@oxlint/binding-darwin-x64@1.77.0':
resolution: {integrity: sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [darwin]
'@oxlint/binding-freebsd-x64@1.77.0':
resolution: {integrity: sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [freebsd]
'@oxlint/binding-linux-arm-gnueabihf@1.77.0':
resolution: {integrity: sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm-musleabihf@1.77.0':
resolution: {integrity: sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm]
os: [linux]
'@oxlint/binding-linux-arm64-gnu@1.77.0':
resolution: {integrity: sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.77.0':
resolution: {integrity: sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.77.0':
resolution: {integrity: sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.77.0':
resolution: {integrity: sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.77.0':
resolution: {integrity: sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.77.0':
resolution: {integrity: sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.77.0':
resolution: {integrity: sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.77.0':
resolution: {integrity: sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.77.0':
resolution: {integrity: sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [openharmony]
'@oxlint/binding-win32-arm64-msvc@1.77.0':
resolution: {integrity: sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [win32]
'@oxlint/binding-win32-ia32-msvc@1.77.0':
resolution: {integrity: sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ia32]
os: [win32]
'@oxlint/binding-win32-x64-msvc@1.77.0':
resolution: {integrity: sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [win32]
'@pkgjs/parseargs@0.11.0':
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
@@ -1385,6 +1510,19 @@ packages:
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
engines: {node: '>= 0.8.0'}
oxlint@1.77.0:
resolution: {integrity: sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg==}
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
peerDependencies:
oxlint-tsgolint: '>=7.0.2001'
vite-plus: '*'
peerDependenciesMeta:
oxlint-tsgolint:
optional: true
vite-plus:
optional: true
p-limit@3.1.0:
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
engines: {node: '>=10'}
@@ -1989,6 +2127,63 @@ snapshots:
'@oxc-project/types@0.139.0': {}
'@oxlint/binding-android-arm-eabi@1.77.0':
optional: true
'@oxlint/binding-android-arm64@1.77.0':
optional: true
'@oxlint/binding-darwin-arm64@1.77.0':
optional: true
'@oxlint/binding-darwin-x64@1.77.0':
optional: true
'@oxlint/binding-freebsd-x64@1.77.0':
optional: true
'@oxlint/binding-linux-arm-gnueabihf@1.77.0':
optional: true
'@oxlint/binding-linux-arm-musleabihf@1.77.0':
optional: true
'@oxlint/binding-linux-arm64-gnu@1.77.0':
optional: true
'@oxlint/binding-linux-arm64-musl@1.77.0':
optional: true
'@oxlint/binding-linux-ppc64-gnu@1.77.0':
optional: true
'@oxlint/binding-linux-riscv64-gnu@1.77.0':
optional: true
'@oxlint/binding-linux-riscv64-musl@1.77.0':
optional: true
'@oxlint/binding-linux-s390x-gnu@1.77.0':
optional: true
'@oxlint/binding-linux-x64-gnu@1.77.0':
optional: true
'@oxlint/binding-linux-x64-musl@1.77.0':
optional: true
'@oxlint/binding-openharmony-arm64@1.77.0':
optional: true
'@oxlint/binding-win32-arm64-msvc@1.77.0':
optional: true
'@oxlint/binding-win32-ia32-msvc@1.77.0':
optional: true
'@oxlint/binding-win32-x64-msvc@1.77.0':
optional: true
'@pkgjs/parseargs@0.11.0':
optional: true
@@ -3143,6 +3338,28 @@ snapshots:
type-check: 0.4.0
word-wrap: 1.2.5
oxlint@1.77.0:
optionalDependencies:
'@oxlint/binding-android-arm-eabi': 1.77.0
'@oxlint/binding-android-arm64': 1.77.0
'@oxlint/binding-darwin-arm64': 1.77.0
'@oxlint/binding-darwin-x64': 1.77.0
'@oxlint/binding-freebsd-x64': 1.77.0
'@oxlint/binding-linux-arm-gnueabihf': 1.77.0
'@oxlint/binding-linux-arm-musleabihf': 1.77.0
'@oxlint/binding-linux-arm64-gnu': 1.77.0
'@oxlint/binding-linux-arm64-musl': 1.77.0
'@oxlint/binding-linux-ppc64-gnu': 1.77.0
'@oxlint/binding-linux-riscv64-gnu': 1.77.0
'@oxlint/binding-linux-riscv64-musl': 1.77.0
'@oxlint/binding-linux-s390x-gnu': 1.77.0
'@oxlint/binding-linux-x64-gnu': 1.77.0
'@oxlint/binding-linux-x64-musl': 1.77.0
'@oxlint/binding-openharmony-arm64': 1.77.0
'@oxlint/binding-win32-arm64-msvc': 1.77.0
'@oxlint/binding-win32-ia32-msvc': 1.77.0
'@oxlint/binding-win32-x64-msvc': 1.77.0
p-limit@3.1.0:
dependencies:
yocto-queue: 0.1.0

View File

@@ -14,6 +14,7 @@ import {
type WaveformLegendOrientation,
type WaveformLegendPosition,
type WaveformOverlayMode,
type WaveformPlotMargin,
type WaveformTitleOptions,
type WaveformZoomEndPayload,
type WaveformZeroLineOptions,
@@ -23,6 +24,7 @@ import { createSimulatedWaveformData } from './data/simulatedWaveforms'
import DemoChartHost from './demo/DemoChartHost.vue'
import DemoControlPanel from './demo/DemoControlPanel.vue'
import type { DemoChartModel, DemoControlPanelModel } from './demo/types'
import { useDemoXAxisLabelControls } from './demo/useDemoXAxisLabelControls'
const fullChartData = createSimulatedWaveformData()
const displayMode = ref<WaveformDisplayMode>('independent')
@@ -40,11 +42,14 @@ const verticalGridVisible = ref(true)
const verticalGridColor = ref('#dfe5ef')
const xAxisLineVisible = ref(false)
const yAxisLineVisible = ref(false)
const { controlModel: xAxisLabelControlModel, xAxisLabelFormatter } = useDemoXAxisLabelControls()
const annotations = ref<WaveformAnnotation[]>([])
const annotationsVisible = ref(true)
const cleanView = ref(false)
const presentationMode = ref(false)
const showTooltip = ref(true)
const plotMarginTop = ref(18)
const plotMarginBottom = ref(52)
const zeroLineVisible = ref(false)
const zeroLineColor = ref('#98a2b3')
const zeroLineWidth = ref(1)
@@ -112,7 +117,10 @@ const frameStyle = computed<WaveformFrameStyle>(() => ({
backgroundColor: frameBackgroundColor.value,
}))
const axes = computed<WaveformAxesOptions>(() => ({
x: { lineVisible: xAxisLineVisible.value },
x: {
lineVisible: xAxisLineVisible.value,
...(xAxisLabelFormatter.value ? { labelFormatter: xAxisLabelFormatter.value } : {}),
},
y: { lineVisible: yAxisLineVisible.value },
}))
const zeroLine = computed<WaveformZeroLineOptions>(() => ({
@@ -121,6 +129,10 @@ const zeroLine = computed<WaveformZeroLineOptions>(() => ({
width: zeroLineWidth.value,
dash: zeroLineDash.value,
}))
const plotMargin = computed<WaveformPlotMargin>(() => ({
top: plotMarginTop.value,
bottom: plotMarginBottom.value,
}))
const initialXValues = normalizeWaveformSeries(fullChartData).flatMap((series) =>
series.points.map((point) => point.x),
@@ -129,9 +141,6 @@ const [initialXMinimum, initialXMaximum] = initialXValues.reduce<[number, number
([minimum, maximum], value) => [Math.min(minimum, value), Math.max(maximum, value)],
[Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY],
)
const initialXSpan = initialXMaximum - initialXMinimum
const minZoomSpan =
Number.isFinite(initialXSpan) && initialXSpan > 0 ? initialXSpan / 40 : undefined
const initialXDomainValue: [number, number] | undefined =
Number.isFinite(initialXMinimum) && Number.isFinite(initialXMaximum)
? [initialXMinimum, initialXMaximum]
@@ -289,6 +298,8 @@ const controlPanelModel = reactive({
displayMode,
overlayMode,
showTooltip,
plotMarginTop,
plotMarginBottom,
cleanView,
presentationMode,
selectedSeriesId,
@@ -308,6 +319,7 @@ const controlPanelModel = reactive({
verticalGridColor,
xAxisLineVisible,
yAxisLineVisible,
...xAxisLabelControlModel,
frameBorderColor,
frameBackgroundColor,
frameBorderWidth,
@@ -338,7 +350,6 @@ const controlPanelModel = reactive({
const chartModel = reactive({
data: displayChartData,
minZoomSpan,
initialXDomain,
displayMode,
overlayMode,
@@ -354,6 +365,7 @@ const chartModel = reactive({
cleanView,
presentationMode,
showTooltip,
plotMargin,
zeroLine,
frameWatermarkVisible,
annotations,

View File

@@ -23,6 +23,7 @@ const props = withDefaults(defineProps<WaveformChartProps>(), {
interactionMode: undefined,
grid: () => ({ rowCount: 2, columnCount: 1, showPagination: true }),
rendering: () => ({}),
plotMargin: () => ({}),
legend: () => ({ position: 'top-right', orientation: 'auto' }),
defaultHiddenSeriesIds: () => [],
cleanView: false,

View File

@@ -19,6 +19,7 @@ const {
overlayMode,
resolvedChartLeftMargin,
titleAreaHeight,
resolvedPlotMargin,
pointerInsideChart,
handleNativeContextMenu,
titleAreaReserved,
@@ -109,7 +110,7 @@ const {
{
'waveform-chart--clean': isCleanView,
'waveform-chart--presentation': isPresentationMode,
'waveform-chart--panning': selection?.mode === 'pan',
'waveform-chart--panning': selection?.kind === 'pan',
},
]"
:style="containerStyle"
@@ -118,6 +119,8 @@ const {
:data-presentation-mode="isPresentationMode"
:data-overlay-mode="overlayMode"
:data-chart-left-margin="resolvedChartLeftMargin"
:data-plot-margin-top="resolvedPlotMargin.top"
:data-plot-margin-bottom="resolvedPlotMargin.bottom"
:data-title-area-height="titleAreaHeight"
@pointerenter="pointerInsideChart = true"
@pointerleave="pointerInsideChart = false"
@@ -244,7 +247,7 @@ const {
/>
<rect
v-if="selectionBox && selection?.mode === 'box'"
v-if="selectionBox && selection?.kind === 'box'"
class="waveform-chart__zoom-selection"
:x="selectionBox.x"
:y="selectionBox.y"
@@ -287,17 +290,17 @@ const {
/>
</g>
</g>
</g>
<text
v-if="resolvedXLabel && !isCleanView"
class="waveform-chart__label waveform-chart__x-label"
:x="innerWidth / 2"
:x="resolvedChartLeftMargin + innerWidth / 2"
:y="xAxisTitleY"
text-anchor="middle"
>
{{ resolvedXLabel }}
</text>
</g>
<text
v-if="hasChartArea && !hasWaveformData"

View File

@@ -7,6 +7,9 @@
/** 图表边距 */
export const margin = { top: 18, right: 24, bottom: 52, left: 64 }
/** Distance from the drawing SVG bottom edge to the X-axis title baseline. */
export const X_AXIS_TITLE_BOTTOM_OFFSET = 12
/**
* 图表最小高度(像素)
*/

View File

@@ -12,8 +12,13 @@ import {
selectSeriesRenderPoints,
type ResolvedWaveformRenderingOptions,
} from '../../core/rendering'
import type { WaveformDisplayMode, WaveformOverlayMode, WaveformPoint } from '../../types'
import { buildMinorTicks, formatEndpointTime } from '../../utils'
import type {
WaveformDisplayMode,
WaveformOverlayMode,
WaveformPoint,
WaveformXAxisLabelFormatter,
} from '../../types'
import { buildMinorTicks, formatXAxisLabel } from '../../utils'
import {
getBottomRowCellIndexes,
type GridCellGeometry,
@@ -45,6 +50,7 @@ export interface BuildTrackLayoutsOptions {
fixedYDomains?: Record<string, [number, number]>
yDomains?: Record<string, [number, number]>
timeUnit: 's' | 'ms'
xAxisLabelFormatter?: WaveformXAxisLabelFormatter
rendering: ResolvedWaveformRenderingOptions
hideSecondaryLabels: boolean
yAxisLabelX: number
@@ -147,8 +153,20 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
const yAxisTickValues = yAxes[0]?.tickValues ?? []
const domain = xScale.domain() as [number, number]
const endpointLabels = {
start: formatEndpointTime(domain[0], domain, options.timeUnit),
end: formatEndpointTime(domain[1], domain, options.timeUnit),
start: formatXAxisLabel(
domain[0],
domain,
options.timeUnit,
'start',
options.xAxisLabelFormatter,
),
end: formatXAxisLabel(
domain[1],
domain,
options.timeUnit,
'end',
options.xAxisLabelFormatter,
),
}
const leftClearance = endpointLabels.start.length * 7 + 10
const rightClearance = endpointLabels.end.length * 7 + 10

View File

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

View File

@@ -11,8 +11,9 @@ import {
import type { AnnotationSeriesCandidate } from '../annotation'
import type { useWaveformAnnotationInteraction } from '../annotation'
import type { DisplaySeries, DisplayTrack } from './types'
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
import type { ResolvedWaveformChartProps, WaveformChartEmit } from './waveformChartTypes'
import { constrainZoomDomain, transformForDomain } from '../interaction/zoomConstraints'
interface LifecycleContext {
props: ResolvedWaveformChartProps
@@ -35,6 +36,7 @@ interface LifecycleContext {
gridOptions: ComputedRef<{ rowCount: number; columnCount: number }>
chartSeries: ComputedRef<DisplaySeries[]>
chartTracks: ComputedRef<DisplayTrack[]>
trackLayouts: ComputedRef<TrackLayout[]>
innerWidth: ComputedRef<number>
innerHeight: ComputedRef<number>
activeInteractionMode: ComputedRef<string | undefined>
@@ -42,6 +44,7 @@ interface LifecycleContext {
internalHiddenSeriesIds: Ref<Set<string>>
independentTransforms: ShallowRef<ZoomTransform[]>
independentYDomains: Ref<Record<number, [number, number]>>
resolveInitialTrackDomain: (track: TrackLayout) => [number, number]
annotationInteraction: ReturnType<typeof useWaveformAnnotationInteraction>
editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
isPresentationMode: ComputedRef<boolean>
@@ -87,6 +90,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
gridOptions,
chartSeries,
chartTracks,
trackLayouts,
innerWidth,
innerHeight,
activeInteractionMode,
@@ -94,6 +98,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
internalHiddenSeriesIds,
independentTransforms,
independentYDomains,
resolveInitialTrackDomain,
annotationInteraction,
editorSeriesOptions,
isPresentationMode,
@@ -139,6 +144,27 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
emit('page-change', nextPage, pageCount.value)
}
let pendingIndependentXDomains: Array<[number, number] | undefined> | undefined
function handleBeforeDataReferenceChange() {
if (props.displayMode !== 'independent') return
pendingIndependentXDomains = trackLayouts.value.map((track) => {
const configuredDomain =
props.initialXDomains?.[track.series.trackId ?? track.series.id] ??
props.initialXDomains?.[track.series.id] ??
props.initialXDomain
if (
!configuredDomain ||
!Number.isFinite(configuredDomain[0]) ||
!Number.isFinite(configuredDomain[1]) ||
configuredDomain[0] === configuredDomain[1]
) {
return undefined
}
return track.xScale.domain() as [number, number]
})
}
function handleDataReferenceChange() {
if (props.displayMode === 'independent') {
const currentTransforms = independentTransforms.value
@@ -148,7 +174,24 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
}
clearHover()
editorSeriesOptions.value = []
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(
@@ -158,6 +201,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
() => props.zoomable,
isPresentationMode,
() => props.minZoomSpan,
() => props.minVisiblePoints,
() => props.initialXDomain,
() => props.initialXDomains,
() => props.displayMode,
@@ -324,5 +368,5 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
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()))
watch(data, (nextData) => {
onBeforeDataChange()
preparedSeries.value = prepareWaveformSeries(nextData)
onDataChange()
})

View File

@@ -24,7 +24,6 @@ import {
normalizeGridOptions,
paginateSeries,
resolveGridCellGeometry,
X_AXIS_BAND,
} from './grid'
import {
buildTrackLayouts,
@@ -285,6 +284,7 @@ export function useWaveformLayout(context: LayoutContext) {
)
: sharedYDomains.value,
timeUnit: props.timeUnit,
xAxisLabelFormatter: props.axes?.x?.labelFormatter,
rendering: renderingOptions.value,
hideSecondaryLabels: isCleanView.value || yAxisLayout.value.hideSecondaryLabels,
yAxisLabelX: yAxisMetrics.value.labelCenterX,
@@ -315,7 +315,6 @@ export function useWaveformLayout(context: LayoutContext) {
)
: [],
)
const xAxisTitleY = computed(() => innerHeight.value + X_AXIS_BAND + 10)
const editorSeries = computed<AnnotationSeriesInfo | undefined>(() => {
const seriesId = annotationInteraction.editorDraft.value?.annotation.seriesId
const series = chartSeries.value.find((item) => item.id === seriesId)
@@ -359,7 +358,6 @@ export function useWaveformLayout(context: LayoutContext) {
resolveSeriesYScale,
annotationTrackLayouts,
renderedAnnotations,
xAxisTitleY,
editorSeries,
resolveFrameNumber,
}

View File

@@ -7,6 +7,7 @@ import {
TITLE_CHAR_WIDTH_RATIO,
TITLE_DEFAULT_FONT_SIZE,
TITLE_LINE_HEIGHT,
X_AXIS_TITLE_BOTTOM_OFFSET,
ZERO_LINE_DEFAULTS,
} from './constants'
import { calculateRotatedTitleLayout, TITLE_AREA_HORIZONTAL_PADDING } from './title'
@@ -144,11 +145,23 @@ export function useWaveformPresentation(context: PresentationContext) {
const titleAreaHeight = computed(() =>
titleAreaReserved.value ? titleLayout.value.areaHeight : 0,
)
const chartTopMargin = computed(() => margin.top)
const resolvePlotMargin = (value: number | undefined, fallback: number) =>
typeof value === 'number' && Number.isFinite(value) && value >= 0 ? value : fallback
const resolvedPlotMargin = computed(() => ({
top: resolvePlotMargin(props.plotMargin.top, margin.top),
bottom: resolvePlotMargin(props.plotMargin.bottom, margin.bottom),
}))
const chartTopMargin = computed(() => resolvedPlotMargin.value.top)
const drawingHeight = computed(() =>
Math.max(0, chartHeight.value - titleAreaHeight.value - paginationBandHeight.value),
)
const innerHeight = computed(() => Math.max(0, drawingHeight.value - margin.top - margin.bottom))
const xAxisTitleY = computed(() => Math.max(0, drawingHeight.value - X_AXIS_TITLE_BOTTOM_OFFSET))
const innerHeight = computed(() =>
Math.max(
0,
drawingHeight.value - resolvedPlotMargin.value.top - resolvedPlotMargin.value.bottom,
),
)
const titleAreaStyle = computed<CSSProperties>(() => ({
height: `${titleAreaHeight.value}px`,
justifyContent:
@@ -193,8 +206,10 @@ export function useWaveformPresentation(context: PresentationContext) {
titleMeasureStyle,
titleLayout,
titleAreaHeight,
resolvedPlotMargin,
chartTopMargin,
drawingHeight,
xAxisTitleY,
innerHeight,
titleAreaStyle,
titleVisualStyle,

View File

@@ -7,6 +7,7 @@ import type {
WaveformInteractionMode,
WaveformLegendOptions,
WaveformOverlayMode,
WaveformPlotMargin,
WaveformPoint,
WaveformRenderingOptions,
WaveformTitleOptions,
@@ -42,6 +43,7 @@ export interface WaveformChartProps {
interactionMode?: WaveformInteractionMode
grid?: WaveformGridOptions
rendering?: WaveformRenderingOptions
plotMargin?: WaveformPlotMargin
title?: WaveformTitleOptions
legend?: WaveformLegendOptions
hiddenSeriesIds?: string[]
@@ -65,6 +67,7 @@ type DefaultedProp =
| 'annotationsVisible'
| 'grid'
| 'rendering'
| 'plotMargin'
| 'legend'
| 'defaultHiddenSeriesIds'
| 'cleanView'
@@ -92,16 +95,17 @@ export interface WaveformChartEmit {
(event: 'page-change', page: number, pageCount: number): void
}
export interface ViewportSelectionState {
interface ViewportSelectionBase {
trackIndex: number
independent: boolean
overlay: SVGRectElement
startX: number
startY: number
currentX: number
currentY: number
pointerId: number
mode: 'box' | 'pan'
xDomain: [number, number]
yDomains: Record<string, [number, number]>
}
export type ViewportSelectionState =
(ViewportSelectionBase & { kind: 'box' }) | (ViewportSelectionBase & { kind: 'pan' })

View File

@@ -13,6 +13,7 @@ export type {
WaveformAnnotationStyle,
WaveformAnnotation,
WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle,
WaveformTitleOptions,
WaveformLegendPosition,

View File

@@ -11,6 +11,7 @@ export type {
WaveformAnnotationStyle,
WaveformAnnotation,
WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle,
WaveformTitleOptions,
WaveformLegendPosition,

View File

@@ -38,9 +38,48 @@ describe('WaveformTooltip', () => {
const tooltip = mountTooltip(100, 200).get('.waveform-tooltip')
expect(tooltip.attributes('style')).toContain('left: 8px')
expect(tooltip.attributes('style')).toContain('max-width: 184px')
expect(tooltip.attributes('style')).not.toContain('right:')
})
it('keeps short content content-sized while exposing the available width cap', () => {
const tooltip = mountTooltip(100).get('.waveform-tooltip')
expect(tooltip.attributes('style')).toContain('left: 112px')
expect(tooltip.attributes('style')).toContain('max-width: 280px')
})
it('keeps long series content in a wrapping content container', () => {
const longName = 'ENG8KJXAc-very-long-series-name-10001'
const pointWithErrors = { x: 1, y: 12, error: 1, upperError: 2 }
const wrapper = mount(WaveformTooltip, {
props: {
visible: true,
position: { x: 100, y: 100 },
timeUnit: 'ms',
hoveredPoint: pointWithErrors,
seriesPoints: [
{
trackIndex: 0,
name: longName,
color: '#f00',
unit: 'very-long-unit',
point: pointWithErrors,
},
],
containerWidth: 400,
containerHeight: 300,
},
})
const tooltip = wrapper.get('.waveform-tooltip')
expect(tooltip.get('.waveform-tooltip__series-content').text()).toContain(longName)
expect(tooltip.get('.waveform-tooltip__series-content').classes()).toContain(
'waveform-tooltip__series-content',
)
expect(tooltip.attributes('style')).toContain('max-width: 280px')
})
it('shows resolved asymmetric errors beside the hovered value', () => {
const pointWithErrors = { x: 1, y: 12, error: 1, upperError: 2 }
const wrapper = mount(WaveformTooltip, {

View File

@@ -33,24 +33,68 @@ const props = defineProps<Props>()
const tooltipGap = 12
const containerPadding = 8
const tooltipMaxWidth = 238
const tooltipPlacementWidth = 238
const tooltipMaxWidth = 320
const tooltipHorizontalPadding = 20
const tooltipLineHeight = 20
function estimateLineCount(text: string, width: number): number {
const contentWidth = Math.max(1, width - tooltipHorizontalPadding - 14)
const charactersPerLine = Math.max(1, Math.floor(contentWidth / 7.2))
return Math.max(1, Math.ceil([...text].length / charactersPerLine))
}
function formatSeriesText(seriesPoint: SeriesPoint): string {
const error = formatError(seriesPoint.point)
return `${seriesPoint.name ? `${seriesPoint.name}: ` : ''}${formatTooltipNumber(seriesPoint.point.y)}${
seriesPoint.unit ? ` ${seriesPoint.unit}` : ''
}${error ? ` ${error}` : ''}`
}
function estimateTooltipHeight(width: number, timeText: string): number {
const seriesLines = props.seriesPoints.reduce(
(total, seriesPoint) => total + estimateLineCount(formatSeriesText(seriesPoint), width),
0,
)
return 16 + tooltipLineHeight * (estimateLineCount(timeText, width) + seriesLines) + 5
}
const tooltipStyle = computed(() => {
if (!props.visible || !props.hoveredPoint) return { display: 'none' }
const estimatedHeight = 44 + props.seriesPoints.length * 22
const rightPlacement = props.position.x + tooltipGap
const leftPlacement = props.position.x - tooltipGap - tooltipMaxWidth
const leftPlacement = props.position.x - tooltipGap - tooltipPlacementWidth
const rightAvailableWidth = props.containerWidth - containerPadding - rightPlacement
const leftAvailableWidth = props.position.x - tooltipGap - containerPadding
const availableWidth = Math.max(
1,
Math.min(tooltipMaxWidth, props.containerWidth - containerPadding * 2),
)
const horizontalStyle =
rightPlacement + tooltipMaxWidth <= props.containerWidth - containerPadding
? { left: `${rightPlacement}px` }
rightPlacement + tooltipPlacementWidth <= props.containerWidth - containerPadding
? {
left: `${rightPlacement}px`,
maxWidth: `${Math.min(tooltipMaxWidth, rightAvailableWidth)}px`,
}
: leftPlacement >= containerPadding
? { right: `${props.containerWidth - props.position.x + tooltipGap}px` }
: { left: `${containerPadding}px` }
? {
right: `${props.containerWidth - props.position.x + tooltipGap}px`,
maxWidth: `${Math.min(tooltipMaxWidth, leftAvailableWidth)}px`,
}
: { left: `${containerPadding}px`, maxWidth: `${availableWidth}px` }
const maxWidth = Number.parseFloat(horizontalStyle.maxWidth)
const timeText = `${props.timeUnit}: ${formatTooltipTime(props.hoveredPoint.x, props.timeUnit)}`
return {
...horizontalStyle,
top: `${Math.max(8, Math.min(props.position.y - 18, props.containerHeight - estimatedHeight - 8))}px`,
top: `${Math.max(
8,
Math.min(
props.position.y - 18,
props.containerHeight - estimateTooltipHeight(maxWidth, timeText) - 8,
),
)}px`,
}
})
@@ -76,6 +120,7 @@ function formatError(point: WaveformPoint): string | null {
class="waveform-tooltip__series waveform-chart__tooltip-series"
>
<i :style="{ backgroundColor: seriesPoint.color }" />
<span class="waveform-tooltip__series-content">
<strong v-if="seriesPoint.name">{{ seriesPoint.name }}:</strong>
<span class="waveform-tooltip__value">
{{ formatTooltipNumber(seriesPoint.point.y)
@@ -83,6 +128,7 @@ function formatError(point: WaveformPoint): string | null {
<small v-if="formatError(seriesPoint.point)">{{ formatError(seriesPoint.point) }}</small>
</span>
</span>
</span>
</div>
</template>
@@ -93,8 +139,8 @@ function formatError(point: WaveformPoint): string | null {
z-index: 2;
display: grid;
gap: 3px;
min-width: 180px;
max-width: 238px;
width: max-content;
max-width: min(320px, calc(100% - 16px));
padding: 8px 10px;
color: #333;
font:
@@ -116,30 +162,34 @@ function formatError(point: WaveformPoint): string | null {
.waveform-tooltip__series {
display: grid;
grid-template-columns: 8px minmax(0, 1fr) auto;
grid-template-columns: 8px minmax(0, 1fr);
gap: 6px;
align-items: center;
align-items: start;
}
.waveform-tooltip__series i {
flex: 0 0 auto;
width: 8px;
height: 8px;
margin-top: 4px;
border-radius: 50%;
}
.waveform-tooltip__series-content {
min-width: 0;
overflow-wrap: anywhere;
white-space: normal;
}
.waveform-tooltip__series strong {
overflow: hidden;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
}
.waveform-tooltip__value {
white-space: nowrap;
overflow-wrap: anywhere;
}
.waveform-tooltip__series small {
color: #667085;
white-space: nowrap;
}
</style>

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

@@ -136,7 +136,7 @@ export function useWaveformHover(context: HoverContext) {
}
const handleSharedPointerMove = (event: PointerEvent) => {
if (isPresentationMode.value) return
if (selection.value?.overlay === event.currentTarget) {
if (selection.value && !selection.value.independent) {
updateViewportDrag(event)
return
}

View File

@@ -1,6 +1,5 @@
import { pointer, scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
import { computed, nextTick, type ComputedRef, type Ref, type ShallowRef } from 'vue'
import { pointer, zoomIdentity, type ZoomTransform } from 'd3'
import { computed, nextTick, shallowRef, type ComputedRef, type Ref, type ShallowRef } from 'vue'
import { MINIMUM_SELECTION_SIZE } from '../core/constants'
import type { DisplayTrack, TrackLayout } from '../core/types'
import { hasFixedYDomainForTrack } from '../core/yDomain'
@@ -10,7 +9,9 @@ import type {
WaveformChartEmit,
} from '../core/waveformChartTypes'
import type { AnnotationSeriesCandidate } from '../annotation'
import { tryReleasePointerCapture } from './pointerCapture'
import { transitionViewportInteraction } from './viewportInteractionState'
import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
interface ViewportContext {
props: ResolvedWaveformChartProps
emit: WaveformChartEmit
@@ -37,7 +38,6 @@ interface ViewportContext {
clearHover: () => void
resolveTrackAtPointer: (pointerX: number, pointerY: number) => TrackLayout | undefined
}
export function useWaveformViewport(context: ViewportContext) {
const {
props,
@@ -65,6 +65,23 @@ export function useWaveformViewport(context: ViewportContext) {
clearHover,
resolveTrackAtPointer,
} = context
const activeOverlay = shallowRef<SVGRectElement>()
const releasePointerCapture = (pointerId: number, event?: PointerEvent) => {
const overlay = activeOverlay.value
const eventTarget = event?.currentTarget as SVGRectElement | null
tryReleasePointerCapture(overlay, pointerId)
if (eventTarget && eventTarget !== overlay) tryReleasePointerCapture(eventTarget, pointerId)
}
const cleanupViewportDrag = (pointerId: number, event?: PointerEvent) => {
const active = selection.value
if (!active || active.pointerId !== pointerId) return
releasePointerCapture(pointerId, event)
selection.value = transitionViewportInteraction(selection.value, {
type: 'cancel',
pointerId,
}).state
activeOverlay.value = undefined
}
const selectionBox = computed(() => {
const active = selection.value
if (!active) return null
@@ -76,48 +93,6 @@ export function useWaveformViewport(context: ViewportContext) {
height: Math.abs(active.currentY - active.startY),
}
})
const transformForDomain = (
domain: [number, number],
baseDomain: [number, number],
width: number,
): ZoomTransform => {
const baseSpan = baseDomain[1] - baseDomain[0]
const span = domain[1] - domain[0]
if (!Number.isFinite(baseSpan) || !Number.isFinite(span) || baseSpan <= 0 || span <= 0) {
return zoomIdentity
}
const scale = baseSpan / span
const baseScale = scaleLinear(baseDomain, [0, width])
return zoomIdentity.translate(-scale * baseScale(domain[0]), 0).scale(scale)
}
const resolveMinimumZoomSpan = (boundary: [number, number]): number => {
const boundarySpan = Math.abs(boundary[1] - boundary[0])
if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return 0
const configured = props.minZoomSpan
if (Number.isFinite(configured) && (configured ?? 0) > 0) {
return Math.min(boundarySpan, configured as number)
}
return boundarySpan / 40
}
const constrainZoomDomain = (
domain: [number, number],
boundary: [number, number],
): [number, number] => {
const normalizedBoundary: [number, number] =
boundary[0] <= boundary[1] ? [...boundary] : [boundary[1], boundary[0]]
const boundarySpan = normalizedBoundary[1] - normalizedBoundary[0]
if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return normalizedBoundary
const requestedStart = Math.min(domain[0], domain[1])
const requestedEnd = Math.max(domain[0], domain[1])
const minimumSpan = resolveMinimumZoomSpan(normalizedBoundary)
const span = Math.max(minimumSpan, Math.min(boundarySpan, requestedEnd - requestedStart))
const center = (requestedStart + requestedEnd) / 2
const start = Math.max(
normalizedBoundary[0],
Math.min(center - span / 2, normalizedBoundary[1] - span),
)
return [start, start + span]
}
const clampDomain = (domain: [number, number], boundary: [number, number]): [number, number] => {
const span = domain[1] - domain[0]
const boundarySpan = boundary[1] - boundary[0]
@@ -143,19 +118,22 @@ export function useWaveformViewport(context: ViewportContext) {
const [rawX, rawY] = pointer(event, overlay)
const x = Math.max(0, Math.min(independent ? track.width : innerWidth.value, rawX))
const y = Math.max(0, Math.min(independent ? track.height : innerHeight.value, rawY))
selection.value = {
const started = transitionViewportInteraction(selection.value, {
type: 'begin',
gesture: {
trackIndex,
independent,
overlay,
startX: x,
startY: y,
currentX: x,
currentY: y,
pointerId: event.pointerId,
mode: panRequested ? 'pan' : 'box',
kind: panRequested ? 'pan' : 'box',
xDomain: track.xScale.domain() as [number, number],
yDomains: currentYDomains(),
}
},
})
if (!started.accepted) return
selection.value = started.state
activeOverlay.value = overlay
overlay.setPointerCapture?.(event.pointerId)
clearHover()
event.preventDefault()
@@ -213,27 +191,34 @@ export function useWaveformViewport(context: ViewportContext) {
const updateViewportDrag = (event: PointerEvent) => {
if (isPresentationMode.value) return
const active = selection.value
if (!active || event.pointerId !== active.pointerId) return
const overlay = activeOverlay.value
if (!active || !overlay || event.pointerId !== active.pointerId) return
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
if (!track) return
const [rawX, rawY] = pointer(event, active.overlay)
active.currentX = Math.max(
const [rawX, rawY] = pointer(event, overlay)
const currentX = Math.max(
0,
Math.min(active.independent ? track.width : innerWidth.value, rawX),
)
active.currentY = Math.max(
const currentY = Math.max(
0,
Math.min(active.independent ? track.height : innerHeight.value, rawY),
)
selection.value = { ...active }
if (active.mode === 'pan') applyPan(active, track)
const transition = transitionViewportInteraction(selection.value, {
type: 'move',
pointerId: event.pointerId,
position: { currentX, currentY },
})
if (!transition.accepted || !transition.state) return
const next = transition.state
selection.value = next
if (next.kind === 'pan') applyPan(next, track)
event.preventDefault()
}
const cancelViewportDrag = (event?: PointerEvent) => {
const active = selection.value
if (!active || (event && event.pointerId !== active.pointerId)) return
active.overlay.releasePointerCapture?.(active.pointerId)
selection.value = null
cleanupViewportDrag(active.pointerId, event)
}
const applyBoxZoom = (active: ViewportSelectionState) => {
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
@@ -249,9 +234,14 @@ export function useWaveformViewport(context: ViewportContext) {
)
if (right - left < MINIMUM_SELECTION_SIZE) return
const baseXDomain = active.independent ? resolveInitialTrackDomain(track) : initialXDomain.value
const groups = active.independent
? [track.seriesList]
: trackLayouts.value.filter((item) => item.hasVisibleSeries).map((item) => item.seriesList)
const xDomain = constrainZoomDomain(
[track.xScale.invert(left), track.xScale.invert(right)],
baseXDomain,
groups,
props,
)
if (active.independent) {
const next = [...independentTransforms.value]
@@ -300,15 +290,38 @@ export function useWaveformViewport(context: ViewportContext) {
}
const active = selection.value
if (!active || event.pointerId !== active.pointerId) return
updateViewportDrag(event)
active.overlay.releasePointerCapture?.(active.pointerId)
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
const overlay = activeOverlay.value
if (!track || !overlay || !overlay.parentNode) {
cleanupViewportDrag(active.pointerId, event)
return
}
const [rawX, rawY] = pointer(event, overlay)
const currentX = Math.max(
0,
Math.min(active.independent ? track.width : innerWidth.value, rawX),
)
const currentY = Math.max(
0,
Math.min(active.independent ? track.height : innerHeight.value, rawY),
)
const completed = transitionViewportInteraction(selection.value, {
type: 'finish',
pointerId: event.pointerId,
position: { currentX, currentY },
}).completed
if (!completed) return
selection.value = null
if (active.mode === 'pan') {
releasePointerCapture(completed.pointerId, event)
activeOverlay.value = undefined
event.preventDefault()
if (completed.kind === 'pan') {
applyPan(completed, track)
void nextTick(configureZoom)
return
}
if (Math.abs(active.currentX - active.startX) >= MINIMUM_SELECTION_SIZE) {
applyBoxZoom(active)
if (Math.abs(completed.currentX - completed.startX) >= MINIMUM_SELECTION_SIZE) {
applyBoxZoom(completed)
}
}
const resetViewport = (trackIndex?: number) => {
@@ -337,7 +350,6 @@ export function useWaveformViewport(context: ViewportContext) {
resetViewport()
emit('zoom-reset')
}
return {
selectionBox,
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 { ResolvedWaveformChartProps, WaveformChartEmit } from '../core/waveformChartTypes'
import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
import {
constrainZoomDomain,
resolveMinimumZoomSpan,
transformForDomain,
type ZoomSeriesGroup,
} from './zoomConstraints'
interface ZoomContext {
props: ResolvedWaveformChartProps
@@ -216,24 +222,38 @@ export function useWaveformZoom(context: ZoomContext) {
.forEach((overlay) => select(overlay).on('.zoom', null))
zoomBehaviors.clear()
}
const resolveMaximumZoomScale = (domain: [number, number]): number => {
const minZoomSpan = props.minZoomSpan
if (!Number.isFinite(minZoomSpan) || (minZoomSpan ?? 0) <= 0) {
return ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE
}
const resolveMaximumZoomScale = (
domain: [number, number],
groups: readonly ZoomSeriesGroup[],
): number => {
const domainSpan = Math.abs(domain[1] - domain[0])
if (!Number.isFinite(domainSpan) || domainSpan <= 0) return ZOOM_CONSTRAINTS.MIN_SCALE
return Math.min(
ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE,
Math.max(ZOOM_CONSTRAINTS.MIN_SCALE, domainSpan / (minZoomSpan ?? domainSpan)),
const minimumSpan = resolveMinimumZoomSpan(domain, groups, props)
return Math.max(
ZOOM_CONSTRAINTS.MIN_SCALE,
minimumSpan > 0 ? domainSpan / minimumSpan : ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE,
)
}
const canZoomTrack = (track: TrackLayout): boolean =>
hasMinimumVisibleXValues(
const constrainTransform = (
transform: ZoomTransform,
domain: [number, number],
width: number,
groups: readonly ZoomSeriesGroup[],
): ZoomTransform => {
const requested = transform.rescaleX(scaleLinear(domain, [0, width])).domain() as [
number,
number,
]
return transformForDomain(constrainZoomDomain(requested, domain, groups, props), domain, width)
}
const canZoomTrack = (track: TrackLayout): boolean => {
const minimum = Number(props.minVisiblePoints)
return hasMinimumVisibleXValues(
track.seriesList,
track.xScale.domain() as [number, number],
Number(props.minVisiblePoints),
Number.isFinite(minimum) && minimum > 0 ? Math.ceil(minimum) + 1 : minimum,
)
}
const canZoomSharedTracks = (): boolean => {
const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
return tracks.length > 0 && tracks.every(canZoomTrack)
@@ -265,9 +285,15 @@ export function useWaveformZoom(context: ZoomContext) {
)
if (!overlay) return
const dataDomain = resolveInitialTrackDomain(track)
const groups = [track.seriesList]
const behavior = zoom<SVGRectElement, unknown>()
.filter((event) => canHandleWheelZoom(event, canZoomTrack(track)))
.scaleExtent([1, resolveMaximumZoomScale(dataDomain)])
.filter((event) => {
const currentTrack =
trackLayouts.value.find((item) => item.index === track.index) ?? track
return canHandleWheelZoom(event, canZoomTrack(currentTrack))
})
.scaleExtent([1, resolveMaximumZoomScale(dataDomain, groups)])
.constrain((transform) => constrainTransform(transform, dataDomain, track.width, groups))
.extent([
[0, 0],
[track.width, track.height],
@@ -293,9 +319,15 @@ export function useWaveformZoom(context: ZoomContext) {
}
const overlay = sharedOverlayElement.value
if (!overlay) return
const groups = trackLayouts.value
.filter((track) => track.hasVisibleSeries)
.map((track) => track.seriesList)
const behavior = zoom<SVGRectElement, unknown>()
.filter((event) => canHandleWheelZoom(event, canZoomSharedTracks()))
.scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value)])
.scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value, groups)])
.constrain((transform) =>
constrainTransform(transform, initialXDomain.value, innerWidth.value, groups),
)
.extent([
[0, 0],
[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

@@ -3,7 +3,7 @@ import { axisBottom, axisLeft, axisRight, select } from 'd3'
import { nextTick, onMounted, ref, watch } from 'vue'
import type { WaveformAxesOptions } from '../../types'
import { formatAxisTime, formatScientificAxisLabel } from '../../utils'
import { formatScientificAxisLabel, formatXAxisLabel } from '../../utils'
import type { DisplaySeries, TrackLayout, WaveformYAxisLayout } from '../core/types'
interface Props {
@@ -66,10 +66,12 @@ function renderAxes() {
axisBottom(props.track.xScale)
.tickValues(props.track.xAxisTickValues)
.tickFormat((value) =>
formatAxisTime(
formatXAxisLabel(
Number(value),
props.timeUnit,
props.track.xScale.domain() as [number, number],
props.timeUnit,
'tick',
props.axes?.x?.labelFormatter,
),
)
.tickSize(-4)
@@ -93,6 +95,7 @@ watch(
() => props.track.xAxisTickValues,
() => props.track.yAxisTickValues,
() => props.timeUnit,
() => props.axes?.x?.labelFormatter,
() => props.axes?.x?.lineVisible,
() => props.axes?.y?.lineVisible,
],

View File

@@ -12,6 +12,7 @@ export type {
WaveformAnnotationStyle,
WaveformAnnotation,
WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle,
WaveformTitleOptions,
WaveformFrameStyle,

View File

@@ -1,7 +1,8 @@
import { flushPromises } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import { describe, expect, it, vi } from 'vitest'
import { flushAnimationFrames } from '../../test/setup'
import type { WaveformXAxisLabelFormatter } from '../../types'
import { type WaveformData } from '../waveform'
import { gridSeries, mountSizedChart } from '../../test/waveformChart'
@@ -162,7 +163,7 @@ describe('WaveformChart', () => {
expect(wrapper.get('.waveform-chart__axis-endpoint--end').attributes('x')).toBe(
String(trackWidth),
)
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4990')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4990.3')
expect(wrapper.find('.waveform-chart__axis-exponent--x').exists()).toBe(false)
})
@@ -267,6 +268,70 @@ describe('WaveformChart', () => {
expect(endpointGroup.attributes('font-size')).toBe(xAxis.attributes('font-size'))
})
it('formats X-axis ticks and endpoints with display-unit values and source context', async () => {
const data: WaveformData = {
kind: 'points',
points: [
{ x: 0.125, y: 0 },
{ x: 1.875, y: 1 },
],
}
const originalPoints = data.points.map((point) => ({ ...point }))
const labelFormatter: WaveformXAxisLabelFormatter = (value, context) =>
`${context.kind}:${value / 10}`
const formatter = vi.fn(labelFormatter)
const wrapper = await mountSizedChart(data, {
timeUnit: 'ms',
axes: { x: { labelFormatter: formatter } },
})
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('start:12.5')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('end:187.5')
expect(
wrapper
.get('.waveform-chart__axis--x')
.findAll('.tick text')
.every((tick) => tick.text().startsWith('tick:')),
).toBe(true)
const startCall = formatter.mock.calls.find(([, context]) => context.kind === 'start')
const tickCall = formatter.mock.calls.find(([, context]) => context.kind === 'tick')
const endCall = formatter.mock.calls.find(([, context]) => context.kind === 'end')
expect(startCall).toEqual([
125,
{
kind: 'start',
rawValue: 0.125,
timeUnit: 'ms',
domain: [0.125, 1.875],
displayDomain: [125, 1875],
},
])
expect(tickCall?.[0]).toBeTypeOf('number')
expect(tickCall?.[1]).toMatchObject({
kind: 'tick',
timeUnit: 'ms',
domain: [0.125, 1.875],
displayDomain: [125, 1875],
})
expect(endCall?.[1]).toMatchObject({ kind: 'end', rawValue: 1.875 })
expect(data.points).toEqual(originalPoints)
await wrapper.setProps({
axes: { x: { labelFormatter: (value: number) => `updated:${value}` } },
})
await flushPromises()
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('updated:125')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('updated:1875')
expect(
wrapper
.get('.waveform-chart__axis--x')
.findAll('.tick text')
.every((tick) => tick.text().startsWith('updated:')),
).toBe(true)
expect(data.points).toEqual(originalPoints)
})
it('keeps zoom-change domains in source seconds', async () => {
const wrapper = await mountSizedChart({
kind: 'points',
@@ -307,6 +372,8 @@ describe('WaveformChart', () => {
)
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe(initialStart)
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).not.toBe(initialEnd)
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toMatch(/^-?\d+$/)
expect(
Number.isFinite(Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())),
).toBe(true)
})
})

View File

@@ -105,6 +105,65 @@ describe('WaveformChart', () => {
expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('300')
})
it('configures plot top and bottom margins and reacts to updates', async () => {
const wrapper = await mountSizedChart(
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
{
displayMode: 'compact',
grid: { rowCount: 1, columnCount: 1 },
plotMargin: { top: 30, bottom: 70 },
},
)
expect(wrapper.attributes('data-plot-margin-top')).toBe('30')
expect(wrapper.attributes('data-plot-margin-bottom')).toBe('70')
expect(wrapper.get('.waveform-chart__overlay').attributes('height')).toBe('260')
expect(wrapper.get('.waveform-chart__svg > g').attributes('transform')).toContain(', 30)')
await wrapper.setProps({ plotMargin: { top: 12 } })
expect(wrapper.attributes('data-plot-margin-top')).toBe('12')
expect(wrapper.attributes('data-plot-margin-bottom')).toBe('52')
expect(wrapper.get('.waveform-chart__overlay').attributes('height')).toBe('296')
})
it('falls back to default plot margins for invalid values', async () => {
const wrapper = await mountSizedChart(
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
{
displayMode: 'compact',
grid: { rowCount: 1, columnCount: 1 },
plotMargin: { top: -1, bottom: Number.NaN },
},
)
expect(wrapper.attributes('data-plot-margin-top')).toBe('18')
expect(wrapper.attributes('data-plot-margin-bottom')).toBe('52')
expect(wrapper.get('.waveform-chart__overlay').attributes('height')).toBe('290')
})
it('keeps the chart title and X-axis title fixed when plot margins change', async () => {
const wrapper = await mountSizedChart(
{ kind: 'samples', values: [0, 1], sampleRate: 1 },
{
displayMode: 'compact',
grid: { rowCount: 1, columnCount: 1 },
title: { text: '固定标题' },
},
)
const titleAreaStyle = wrapper.get('.waveform-chart__title-area').attributes('style')
const xAxisTitle = wrapper.get('.waveform-chart__x-label')
const initialX = xAxisTitle.attributes('x')
const initialY = xAxisTitle.attributes('y')
await wrapper.setProps({ plotMargin: { top: 60, bottom: 90 } })
expect(wrapper.get('.waveform-chart__title-area').attributes('style')).toBe(titleAreaStyle)
expect(wrapper.get('.waveform-chart__x-label').attributes('x')).toBe(initialX)
expect(wrapper.get('.waveform-chart__x-label').attributes('y')).toBe(initialY)
expect(wrapper.get('.waveform-chart__svg > g').attributes('transform')).toContain(', 60)')
})
it('does not render or reserve space for missing, hidden, or blank titles', async () => {
for (const title of [undefined, { visible: false, text: '隐藏标题' }, { text: ' ' }]) {
const wrapper = await mountSizedChart(

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

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

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

@@ -6,16 +6,15 @@ const END_TIME = 5
const TWO_PI = Math.PI * 2
type SignalGenerator = (time: number, noise: number) => number
type ErrorGenerator = (time: number, value: number) => Pick<
WaveformPoint,
'error' | 'lowerError' | 'upperError'
>
type ErrorGenerator = (
time: number,
value: number,
) => Pick<WaveformPoint, 'error' | 'lowerError' | 'upperError'>
interface SimulatedSeriesDefinition
extends Pick<
interface SimulatedSeriesDefinition extends Pick<
WaveformSeries,
'id' | 'name' | 'unit' | 'lineType' | 'pointType' | 'errorBar'
> {
> {
signal: SignalGenerator
errors?: ErrorGenerator
}
@@ -39,7 +38,7 @@ function createPoints(
return {
x: time,
y: value,
...(errors?.(time, value) ?? {}),
...errors?.(time, value),
}
})
}

View File

@@ -22,8 +22,7 @@ defineExpose({ resetViewport })
v-model:annotations="model.annotations"
v-model:hidden-series-ids="model.hiddenSeriesIds"
:data="model.data"
:min-zoom-span="model.minZoomSpan"
:min-visible-points="5"
:min-visible-points="2"
:initial-x-domain="model.initialXDomain"
:display-mode="model.displayMode"
:overlay-mode="model.overlayMode"
@@ -45,6 +44,7 @@ defineExpose({ resetViewport })
:clean-view="model.cleanView"
:presentation-mode="model.presentationMode"
:show-tooltip="model.showTooltip"
:plot-margin="model.plotMargin"
:zero-line="model.zeroLine"
:frame-number="model.frameWatermarkVisible ? 1 : undefined"
:annotations-visible="model.annotationsVisible"

View File

@@ -8,6 +8,35 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
</script>
<template>
<section class="control-section">
<h2>绘图区边距</h2>
<div class="plot-margin-controls">
<label class="frame-style-control">
<span>上边距</span>
<InputNumber
v-model:value="model.plotMarginTop"
:min="0"
:max="300"
:step="1"
addon-after="px"
size="small"
aria-label="绘图区上边距"
/>
</label>
<label class="frame-style-control">
<span>下边距</span>
<InputNumber
v-model:value="model.plotMarginBottom"
:min="0"
:max="300"
:step="1"
addon-after="px"
size="small"
aria-label="绘图区下边距"
/>
</label>
</div>
</section>
<section class="control-section">
<div class="control-section__header">
<h2>零值参考线</h2>
@@ -96,6 +125,76 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
<Switch v-model:checked="model.yAxisLineVisible" size="small" aria-label="显示纵轴线" />
</div>
</div>
<div class="x-axis-label-controls">
<label class="frame-style-control frame-style-control--switch">
<span>自定义 Label</span>
<Switch
v-model:checked="model.xAxisLabelFormatterEnabled"
size="small"
aria-label="自定义 X Label"
/>
</label>
<label v-if="model.xAxisLabelFormatterEnabled" class="frame-style-control">
<span>格式类型</span>
<Select
v-model:value="model.xAxisLabelFormat"
:options="model.xAxisLabelFormatOptions"
size="small"
aria-label="X Label 格式类型"
/>
</label>
<label
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'number'"
class="frame-style-control"
>
<span>运算倍率</span>
<InputNumber
v-model:value="model.xAxisLabelMultiplier"
:min="-1000000"
:max="1000000"
:step="0.1"
size="small"
aria-label="X Label 运算倍率"
/>
</label>
<label
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'number'"
class="frame-style-control"
>
<span>小数位数</span>
<InputNumber
v-model:value="model.xAxisLabelFractionDigits"
:min="0"
:max="12"
:step="1"
size="small"
aria-label="X Label 小数位数"
/>
</label>
<label
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'datetime'"
class="frame-style-control"
>
<span>时区</span>
<Select
v-model:value="model.xAxisLabelTimeZone"
:options="model.xAxisLabelTimeZoneOptions"
size="small"
aria-label="X Label 时区"
/>
</label>
<label
v-if="model.xAxisLabelFormatterEnabled && model.xAxisLabelFormat === 'datetime'"
class="frame-style-control frame-style-control--switch"
>
<span>显示毫秒</span>
<Switch
v-model:checked="model.xAxisLabelShowMilliseconds"
size="small"
aria-label="X Label 显示毫秒"
/>
</label>
</div>
</section>
<section class="control-section">
<h2>图框样式</h2>

View File

@@ -0,0 +1,29 @@
import { flushPromises, mount } from '@vue/test-utils'
import { InputNumber } from 'ant-design-vue'
import { describe, expect, it } from 'vitest'
import App from '../App.vue'
import { WaveformChart } from '../components'
describe('plot margin demo controls', () => {
it('updates the chart top and bottom margins from the sidebar', async () => {
const wrapper = mount(App)
await flushPromises()
const chart = wrapper.getComponent(WaveformChart)
const controls = wrapper.get('.plot-margin-controls').findAllComponents(InputNumber)
expect(controls).toHaveLength(2)
expect(controls[0]?.props('value')).toBe(18)
expect(controls[1]?.props('value')).toBe(52)
expect(chart.props('plotMargin')).toEqual({ top: 18, bottom: 52 })
controls[0]?.vm.$emit('update:value', 30)
controls[1]?.vm.$emit('update:value', 70)
await flushPromises()
expect(chart.props('plotMargin')).toEqual({ top: 30, bottom: 70 })
expect(wrapper.get('.waveform-chart').attributes('data-plot-margin-top')).toBe('30')
expect(wrapper.get('.waveform-chart').attributes('data-plot-margin-bottom')).toBe('70')
wrapper.unmount()
})
})

View File

@@ -10,10 +10,12 @@ import type {
WaveformLegendPosition,
WaveformLineStyle,
WaveformOverlayMode,
WaveformPlotMargin,
WaveformTitleOptions,
WaveformZeroLineOptions,
WaveformZoomEndPayload,
} from '../components'
import type { DemoXAxisLabelFormat, DemoXAxisLabelTimeZone } from './useDemoXAxisLabelControls'
interface SelectOption<T> {
label: string
@@ -25,6 +27,8 @@ export interface DemoControlPanelModel {
displayMode: WaveformDisplayMode
overlayMode: WaveformOverlayMode
showTooltip: boolean
plotMarginTop: number
plotMarginBottom: number
cleanView: boolean
presentationMode: boolean
selectedSeriesId: string
@@ -44,6 +48,14 @@ export interface DemoControlPanelModel {
verticalGridColor: string
xAxisLineVisible: boolean
yAxisLineVisible: boolean
xAxisLabelFormatterEnabled: boolean
xAxisLabelFormat: DemoXAxisLabelFormat
xAxisLabelFormatOptions: Array<SelectOption<DemoXAxisLabelFormat>>
xAxisLabelMultiplier: number
xAxisLabelFractionDigits: number
xAxisLabelTimeZone: DemoXAxisLabelTimeZone
xAxisLabelTimeZoneOptions: Array<SelectOption<DemoXAxisLabelTimeZone>>
xAxisLabelShowMilliseconds: boolean
frameBorderColor: string
frameBackgroundColor: string
frameBorderWidth: number
@@ -74,7 +86,6 @@ export interface DemoControlPanelModel {
export interface DemoChartModel {
data: WaveformData
minZoomSpan?: number
initialXDomain?: [number, number]
displayMode: WaveformDisplayMode
overlayMode: WaveformOverlayMode
@@ -90,6 +101,7 @@ export interface DemoChartModel {
cleanView: boolean
presentationMode: boolean
showTooltip: boolean
plotMargin: WaveformPlotMargin
zeroLine: WaveformZeroLineOptions
frameWatermarkVisible: boolean
annotations: WaveformAnnotation[]

View File

@@ -0,0 +1,77 @@
import { computed, ref } from 'vue'
import type { WaveformXAxisLabelFormatter } from '../types'
export type DemoXAxisLabelFormat = 'number' | 'datetime'
export type DemoXAxisLabelTimeZone = 'local' | 'Asia/Shanghai' | 'UTC'
const formatOptions = [
{ label: '数值', value: 'number' as const },
{ label: '固定时间', value: 'datetime' as const },
]
const timeZoneOptions = [
{ label: '中国标准时间', value: 'Asia/Shanghai' as const },
{ label: '本地时区', value: 'local' as const },
{ label: 'UTC', value: 'UTC' as const },
]
function pad(value: number, length = 2): string {
return String(value).padStart(length, '0')
}
export function formatDemoTimestamp(
value: number,
timeZone: DemoXAxisLabelTimeZone,
showMilliseconds: boolean,
): string {
const offset = timeZone === 'Asia/Shanghai' ? 8 * 60 * 60 * 1000 : 0
const date = new Date(value + offset)
if (!Number.isFinite(date.getTime())) return String(value)
const useUtcFields = timeZone !== 'local'
const year = useUtcFields ? date.getUTCFullYear() : date.getFullYear()
const month = (useUtcFields ? date.getUTCMonth() : date.getMonth()) + 1
const day = useUtcFields ? date.getUTCDate() : date.getDate()
const hours = useUtcFields ? date.getUTCHours() : date.getHours()
const minutes = useUtcFields ? date.getUTCMinutes() : date.getMinutes()
const seconds = useUtcFields ? date.getUTCSeconds() : date.getSeconds()
const milliseconds = useUtcFields ? date.getUTCMilliseconds() : date.getMilliseconds()
const suffix = showMilliseconds ? `.${pad(milliseconds, 3)}` : ''
return `${year}-${pad(month)}-${pad(day)} ${pad(hours)}:${pad(minutes)}:${pad(seconds)}${suffix}`
}
export function useDemoXAxisLabelControls() {
const xAxisLabelFormatterEnabled = ref(false)
const xAxisLabelFormat = ref<DemoXAxisLabelFormat>('number')
const xAxisLabelMultiplier = ref(1)
const xAxisLabelFractionDigits = ref(3)
const xAxisLabelTimeZone = ref<DemoXAxisLabelTimeZone>('Asia/Shanghai')
const xAxisLabelShowMilliseconds = ref(false)
const xAxisLabelFormatter = computed<WaveformXAxisLabelFormatter | undefined>(() => {
if (!xAxisLabelFormatterEnabled.value) return undefined
if (xAxisLabelFormat.value === 'datetime') {
return (value) =>
formatDemoTimestamp(value, xAxisLabelTimeZone.value, xAxisLabelShowMilliseconds.value)
}
const multiplier = Number.isFinite(xAxisLabelMultiplier.value) ? xAxisLabelMultiplier.value : 1
const fractionDigits = Number.isFinite(xAxisLabelFractionDigits.value)
? Math.min(12, Math.max(0, Math.trunc(xAxisLabelFractionDigits.value)))
: 0
return (value) => (value * multiplier).toFixed(fractionDigits)
})
return {
controlModel: {
xAxisLabelFormatterEnabled,
xAxisLabelFormat,
xAxisLabelFormatOptions: formatOptions,
xAxisLabelMultiplier,
xAxisLabelFractionDigits,
xAxisLabelTimeZone,
xAxisLabelTimeZoneOptions: timeZoneOptions,
xAxisLabelShowMilliseconds,
},
xAxisLabelFormatter,
}
}

View File

@@ -0,0 +1,86 @@
import { flushPromises, mount } from '@vue/test-utils'
import { InputNumber, Select } from 'ant-design-vue'
import { describe, expect, it } from 'vitest'
import App from '../App.vue'
import { WaveformChart } from '../components'
import { formatDemoTimestamp } from './useDemoXAxisLabelControls'
describe('X-axis label demo controls', () => {
it('formats timestamps in fixed UTC and China-standard-time formats', () => {
expect(formatDemoTimestamp(0, 'UTC', false)).toBe('1970-01-01 00:00:00')
expect(formatDemoTimestamp(123, 'UTC', true)).toBe('1970-01-01 00:00:00.123')
expect(formatDemoTimestamp(0, 'Asia/Shanghai', false)).toBe('1970-01-01 08:00:00')
expect(formatDemoTimestamp(Number.NaN, 'UTC', false)).toBe('NaN')
})
it('configures numeric and timestamp labels from the sidebar', async () => {
const wrapper = mount(App)
await flushPromises()
const chart = wrapper.getComponent(WaveformChart)
expect(chart.props('axes')).toEqual({
x: { lineVisible: false },
y: { lineVisible: false },
})
expect(wrapper.find('[aria-label="自定义 X 轴 Label"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="X 轴 Label 小数位数"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="X 轴 Label 格式类型"]').exists()).toBe(false)
await wrapper.get('[aria-label="自定义 X 轴 Label"]').trigger('click')
await flushPromises()
const inputNumbers = wrapper.findAllComponents(InputNumber)
const multiplierInput = inputNumbers.find((input) =>
input.find('[aria-label="X 轴 Label 运算倍率"]').exists(),
)
const fractionDigitsInput = inputNumbers.find((input) =>
input.find('[aria-label="X 轴 Label 小数位数"]').exists(),
)
expect(multiplierInput).toBeDefined()
expect(fractionDigitsInput).toBeDefined()
expect(multiplierInput?.props('value')).toBe(1)
expect(fractionDigitsInput?.props('value')).toBe(3)
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toMatch(/\.000$/)
multiplierInput?.vm.$emit('update:value', 2)
fractionDigitsInput?.vm.$emit('update:value', 2)
await flushPromises()
const formatter = chart.props('axes')?.x?.labelFormatter
expect(formatter).toBeTypeOf('function')
expect(formatter?.(1.234, {} as never)).toBe('2.47')
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toMatch(/\.00$/)
const formatSelect = wrapper
.findAllComponents(Select)
.find((select) => select.find('[aria-label="X 轴 Label 格式类型"]').exists())
expect(formatSelect?.props('value')).toBe('number')
formatSelect?.vm.$emit('update:value', 'datetime')
await flushPromises()
expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="X 轴 Label 小数位数"]').exists()).toBe(false)
expect(wrapper.find('[aria-label="X 轴 Label 时区"]').exists()).toBe(true)
expect(wrapper.find('[aria-label="X 轴 Label 显示毫秒"]').exists()).toBe(true)
expect(chart.props('axes')?.x?.labelFormatter?.(0, {} as never)).toBe('1970-01-01 08:00:00')
await wrapper.get('[aria-label="X 轴 Label 显示毫秒"]').trigger('click')
await flushPromises()
expect(chart.props('axes')?.x?.labelFormatter?.(123, {} as never)).toBe(
'1970-01-01 08:00:00.123',
)
const timeZoneSelect = wrapper
.findAllComponents(Select)
.find((select) => select.find('[aria-label="X 轴 Label 时区"]').exists())
timeZoneSelect?.vm.$emit('update:value', 'UTC')
await flushPromises()
expect(chart.props('axes')?.x?.labelFormatter?.(123, {} as never)).toBe(
'1970-01-01 00:00:00.123',
)
await wrapper.get('[aria-label="自定义 X 轴 Label"]').trigger('click')
await flushPromises()
expect(chart.props('axes')?.x?.labelFormatter).toBeUndefined()
expect(wrapper.find('[aria-label="X 轴 Label 运算倍率"]').exists()).toBe(false)
wrapper.unmount()
})
})

View File

@@ -17,12 +17,16 @@ export type {
WaveformAnnotationStyle,
WaveformAnnotation,
WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle,
WaveformTitleOptions,
WaveformLegendPosition,
WaveformLegendOrientation,
WaveformLegendOptions,
WaveformFrameStyle,
WaveformXAxisLabelKind,
WaveformXAxisLabelFormatterContext,
WaveformXAxisLabelFormatter,
WaveformAxesOptions,
WaveformZeroLineOptions,
// 数据类型

View File

@@ -133,6 +133,14 @@ body {
grid-template-columns: minmax(0, 1fr) auto;
}
.x-axis-label-controls {
display: grid;
gap: 10px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid #eaecf0;
}
.grid-line-color-picker,
.grid-line-color-picker .vc-color-wrap {
width: 48px;
@@ -187,6 +195,7 @@ body {
}
.frame-style-controls,
.plot-margin-controls,
.auxiliary-style-controls {
display: grid;
gap: 10px;

View File

@@ -74,6 +74,14 @@ export interface WaveformRenderingOptions {
errorBarMinSpacing?: number
}
/** Pixel margins reserved above and below the waveform plotting area. */
export interface WaveformPlotMargin {
/** Space between the top of the drawing SVG and the plotting area. Defaults to 18. */
top?: number
/** Space between the plotting area and the bottom of the drawing SVG. Defaults to 52. */
bottom?: number
}
/** Text styling for the chart-level title. */
export interface WaveformTitleTextStyle {
color?: string
@@ -121,10 +129,31 @@ export interface WaveformFrameStyle {
backgroundColor?: string
}
export type WaveformXAxisLabelKind = 'tick' | 'start' | 'end'
/** Context passed to custom X-axis label formatters. */
export interface WaveformXAxisLabelFormatterContext {
kind: WaveformXAxisLabelKind
/** X coordinate in the source data, before time-unit conversion. */
rawValue: number
timeUnit: 's' | 'ms'
/** Current visible X domain in source coordinates. */
domain: [number, number]
/** Current visible X domain converted to the selected display unit. */
displayDomain: [number, number]
}
export type WaveformXAxisLabelFormatter = (
value: number,
context: WaveformXAxisLabelFormatterContext,
) => string
/** Controls axis baseline visibility while preserving tick marks and axis text. */
export interface WaveformAxesOptions {
x?: {
lineVisible?: boolean
/** Formats display-unit X values for ticks and visible-range endpoints. */
labelFormatter?: WaveformXAxisLabelFormatter
}
y?: {
lineVisible?: boolean

View File

@@ -12,12 +12,16 @@ export type {
WaveformAnnotationStyle,
WaveformAnnotation,
WaveformRenderingOptions,
WaveformPlotMargin,
WaveformTitleTextStyle,
WaveformTitleOptions,
WaveformLegendPosition,
WaveformLegendOrientation,
WaveformLegendOptions,
WaveformFrameStyle,
WaveformXAxisLabelKind,
WaveformXAxisLabelFormatterContext,
WaveformXAxisLabelFormatter,
WaveformAxesOptions,
WaveformZeroLineOptions,
} from './chart'

View File

@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { WaveformXAxisLabelFormatter, WaveformXAxisLabelFormatterContext } from '../types'
import {
formatAnnotationTime,
formatAxisTime,
@@ -9,6 +10,7 @@ import {
formatScientificAxisLabel,
formatTooltipNumber,
formatTooltipTime,
formatXAxisLabel,
resolveScientificAxisExponent,
shouldUseScientificAxisLabel,
} from './formatters'
@@ -38,10 +40,11 @@ describe('waveform number formatters', () => {
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(-10_000, 3000)).toBe(3)
expect(resolveScientificAxisExponent(-1000, 0)).toBeNull()
expect(resolveScientificAxisExponent(-100_000, -3000)).toBe(5)
expect(resolveScientificAxisExponent(-10_000, 3000)).toBe(4)
expect(resolveScientificAxisExponent(-1000, 0)).toBe(3)
expect(resolveScientificAxisExponent(0, 0)).toBeNull()
})
@@ -55,20 +58,50 @@ describe('waveform number formatters', () => {
expect(formatScientificAxisExponent(0, 0)).toBeNull()
})
it('formats X-axis ticks and endpoints as plain integers in the selected display unit', () => {
it('formats X-axis ticks and endpoints as complete plain values in the display unit', () => {
const domain: [number, number] = [0, 1]
expect(formatAxisTime(0.5004, 'ms', domain)).toBe('500')
expect(formatAxisTime(0.5004, 'ms', domain)).toBe('500.4')
expect(formatEndpointTime(1, domain, 'ms')).toBe('1000')
expect(formatAxisTime(0.5, 's', domain)).toBe('1')
expect(formatAxisTime(0.5, 's', domain)).toBe('0.5')
expect(formatEndpointTime(1, domain, 's')).toBe('1')
const tinyDomain: [number, number] = [0, 0.000001]
expect(formatEndpointTime(0.000001, tinyDomain, 's')).toBe('0')
expect(formatAxisTime(-0.000001, 's', tinyDomain)).toBe('0')
expect(formatEndpointTime(0.000001, tinyDomain, 's')).toBe('0.000001')
expect(formatAxisTime(-0.000001, 's', tinyDomain)).toBe('-0.000001')
expect(formatAxisTime(-0, 's')).toBe('0')
expect(formatAxisTime(1e21, 's')).toBe('1000000000000000000000')
expect(formatAxisTime(Number.POSITIVE_INFINITY, 's')).toBe('Infinity')
})
it('passes display values and complete source context to X-axis label formatters', () => {
const domain: [number, number] = [0.125, 1.875]
const formatter: WaveformXAxisLabelFormatter = (value, context) =>
`${context.kind}:${value / 10}`
expect(formatXAxisLabel(0.5, domain, 'ms', 'tick', formatter)).toBe('tick:50')
expect(formatXAxisLabel(domain[0], domain, 'ms', 'start', formatter)).toBe('start:12.5')
expect(formatXAxisLabel(domain[1], domain, 'ms', 'end', formatter)).toBe('end:187.5')
let receivedContext: WaveformXAxisLabelFormatterContext | undefined
formatXAxisLabel(0.5, domain, 'ms', 'tick', (value, context) => {
receivedContext = context
return String(value)
})
expect(receivedContext).toEqual({
kind: 'tick',
rawValue: 0.5,
timeUnit: 'ms',
domain: [0.125, 1.875],
displayDomain: [125, 1875],
})
expect(() =>
formatXAxisLabel(0.5, domain, 's', 'tick', () => {
throw new Error('formatter failed')
}),
).toThrow('formatter failed')
})
it('formats tooltip and raw values for their display contexts', () => {
expect(formatTooltipNumber(12345.67891)).toBe('12,345.6789')
expect(formatTooltipNumber(-0)).toBe('0')

View File

@@ -1,3 +1,5 @@
import type { WaveformXAxisLabelFormatter, WaveformXAxisLabelKind } from '../types'
/**
* 时间单位类型
*/
@@ -22,10 +24,6 @@ const Y_AXIS_NUMBER_FORMATTER = new Intl.NumberFormat('zh-CN', {
const TOOLTIP_NUMBER_FORMATTER = new Intl.NumberFormat('zh-CN', {
maximumFractionDigits: 4,
})
const X_AXIS_TIME_FORMATTER = new Intl.NumberFormat('zh-CN', {
maximumFractionDigits: 0,
useGrouping: false,
})
function formatFixedNumber(value: number, precision: number): string {
const formatted = value.toFixed(Math.max(0, precision))
@@ -44,8 +42,7 @@ export function shouldUseScientificAxisLabel(maxAbsoluteValue: number): boolean
export function resolveScientificAxisExponent(axisMin?: number, axisMax?: number): number | null {
if (typeof axisMin !== 'number' || typeof axisMax !== 'number') return null
const maxValue = Math.max(axisMin, axisMax)
const absoluteMaxValue = Math.abs(maxValue)
const absoluteMaxValue = Math.max(Math.abs(axisMin), Math.abs(axisMax))
return shouldUseScientificAxisLabel(absoluteMaxValue)
? Math.floor(Math.log10(absoluteMaxValue))
: null
@@ -103,13 +100,6 @@ export function formatTooltipNumber(value: number): string {
return TOOLTIP_NUMBER_FORMATTER.format(value)
}
/** Format an X-axis time value as a plain integer without grouping separators. */
function formatAxisTimeValue(value: number): string {
if (!Number.isFinite(value)) return String(value)
const formatted = X_AXIS_TIME_FORMATTER.format(value)
return formatted === '-0' ? '0' : formatted
}
/** Convert a number to complete plain decimal text without forcing exponential notation. */
export function formatPlainNumber(value: number): string {
if (!Number.isFinite(value)) return String(value)
@@ -143,6 +133,26 @@ export function displayTime(value: number, timeUnit: TimeUnit): number {
return timeUnit === 'ms' ? value * 1000 : value
}
/** Format an X-axis value without changing its source coordinate. */
export function formatXAxisLabel(
rawValue: number,
domain: [number, number],
timeUnit: TimeUnit,
kind: WaveformXAxisLabelKind,
formatter?: WaveformXAxisLabelFormatter,
): string {
const value = displayTime(rawValue, timeUnit)
if (!formatter) return formatPlainNumber(value)
return formatter(value, {
kind,
rawValue,
timeUnit,
domain: [domain[0], domain[1]],
displayDomain: [displayTime(domain[0], timeUnit), displayTime(domain[1], timeUnit)],
})
}
/**
* 计算端点标签的小数位数
* @param domain 数据域 [最小值, 最大值]
@@ -158,7 +168,7 @@ export function endpointFractionDigits(domain: [number, number], timeUnit: TimeU
}
/**
* 将 X 轴端点时间格式化为当前显示单位下的普通整数
* 将 X 轴端点时间格式化为当前显示单位下的普通十进制文本
* @param value 时间值(秒)
* @param _domain 数据域(为保持现有调用签名而保留)
* @param timeUnit 时间单位
@@ -169,11 +179,11 @@ export function formatEndpointTime(
_domain: [number, number],
timeUnit: TimeUnit,
): string {
return formatAxisTimeValue(displayTime(value, timeUnit))
return formatXAxisLabel(value, _domain, timeUnit, 'end')
}
/**
* 将 X 轴时间刻度格式化为当前显示单位下的普通整数
* 将 X 轴时间刻度格式化为当前显示单位下的普通十进制文本
* @param value 时间值(秒)
* @param timeUnit 时间单位
* @param _domain 数据域(为保持现有调用签名而保留)
@@ -184,8 +194,7 @@ export function formatAxisTime(
timeUnit: TimeUnit,
_domain?: [number, number],
): string {
void _domain
return formatAxisTimeValue(displayTime(value, timeUnit))
return formatXAxisLabel(value, _domain ?? [value, value], timeUnit, 'tick')
}
/**

View File

@@ -11,6 +11,7 @@ export {
endpointFractionDigits,
formatEndpointTime,
formatAxisTime,
formatXAxisLabel,
formatTooltipTime,
formatAnnotationTime,
formatPlainNumber,

View File

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