Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d79185b952 | ||
|
|
4dd53aae11 | ||
|
|
9b42e3797b | ||
|
|
7646bf4907 | ||
|
|
e2725e5569 | ||
|
|
9eb1f0f137 |
1
.github/workflows/ci.yml
vendored
1
.github/workflows/ci.yml
vendored
@@ -19,6 +19,7 @@ jobs:
|
|||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
- run: pnpm typecheck
|
- run: pnpm typecheck
|
||||||
- run: pnpm check:file-length
|
- run: pnpm check:file-length
|
||||||
|
- run: pnpm lint:oxlint
|
||||||
- run: pnpm lint
|
- run: pnpm lint
|
||||||
- run: pnpm test:coverage
|
- run: pnpm test:coverage
|
||||||
- run: pnpm build
|
- run: pnpm build
|
||||||
|
|||||||
8
.oxlintrc.json
Normal file
8
.oxlintrc.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"env": {
|
||||||
|
"browser": true,
|
||||||
|
"node": true
|
||||||
|
},
|
||||||
|
"ignorePatterns": ["dist/**", "dist-demo/**", "coverage/**", "node_modules/**"]
|
||||||
|
}
|
||||||
@@ -668,12 +668,20 @@ pnpm dev
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm typecheck
|
pnpm typecheck
|
||||||
|
pnpm lint:oxlint
|
||||||
pnpm lint
|
pnpm lint
|
||||||
|
pnpm lint:all
|
||||||
|
pnpm format:check
|
||||||
pnpm test
|
pnpm test
|
||||||
pnpm test:coverage
|
pnpm test:coverage
|
||||||
pnpm build
|
pnpm build
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`pnpm lint:oxlint` 使用 Oxlint 的默认 correctness 检查及内置 TypeScript、Unicorn 和 Oxc
|
||||||
|
插件,自动忽略 `dist/`、`dist-demo/`、`coverage/` 和 `node_modules/`。`pnpm lint` 继续负责
|
||||||
|
ESLint 的 Vue SFC、TypeScript ESLint 和 `max-lines` 规则;`pnpm lint:all` 会依次运行两者。
|
||||||
|
`pnpm format:check` 只读检查 Prettier 格式,`pnpm format` 保持原有的写入行为。
|
||||||
|
|
||||||
`pnpm build` 同时生成 `dist/` 组件库产物和 `dist-demo/` 演示应用。正式公开入口为
|
`pnpm build` 同时生成 `dist/` 组件库产物和 `dist-demo/` 演示应用。正式公开入口为
|
||||||
`src/index.ts`,样式入口为 `src/styles.css`;`dist/` 和 `dist-demo/` 均为生成目录,不要手工编辑。
|
`src/index.ts`,样式入口为 `src/styles.css`;`dist/` 和 `dist-demo/` 均为生成目录,不要手工编辑。
|
||||||
|
|
||||||
|
|||||||
37
docs/architecture.md
Normal file
37
docs/architecture.md
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
Waveform Analysis uses a functional architecture around Vue Composition API. Vue composables own
|
||||||
|
reactive orchestration and DOM resources, while pure functions own stateless calculations and
|
||||||
|
state transitions.
|
||||||
|
|
||||||
|
## Boundaries
|
||||||
|
|
||||||
|
- `useWaveformChartController` is the facade for the chart. It composes composables and exposes the
|
||||||
|
existing reactive controller surface; it owns no independent copy of component props.
|
||||||
|
- `useWaveformViewport` keeps refs, computed values, pointer events, DOM capture, D3 coordinates,
|
||||||
|
and emitted events. `transitionViewportInteraction` and `reduceViewportInteraction` are pure
|
||||||
|
reducer functions for the legal `begin`, `move`, `finish`, `cancel`, and `reset` transitions.
|
||||||
|
The composable's `selection` shallow ref is the only interaction state source; SVG overlays and
|
||||||
|
pointer capture remain DOM resources local to the composable.
|
||||||
|
- `RenderablePointSelectionStrategy` is a function type defining the replaceable rendering
|
||||||
|
algorithm boundary. `completePointSelectionStrategy` preserves the complete visible source range
|
||||||
|
and `peakPreservingPointSelectionStrategy` preserves first/minimum/maximum/last points per
|
||||||
|
bucket. `resolveRenderablePointSelectionStrategy` selects the function from rendering options.
|
||||||
|
The existing `selectRenderablePoints` function remains the compatibility facade used by rendering.
|
||||||
|
- `normalizeWaveformData` and `normalizeWaveformSeries` are functional adapters from public data
|
||||||
|
shapes to the internal series model. `buildTrackLayouts` remains a functional builder because
|
||||||
|
layout construction is a stateless calculation, not a long-lived object.
|
||||||
|
|
||||||
|
## Vue Integration
|
||||||
|
|
||||||
|
The viewport `selection` is stored in a shallow ref and updated only with reducer transitions. SVG
|
||||||
|
overlay elements remain in the composable as DOM resources. Presentation mode, annotation editing,
|
||||||
|
scales, domains, ticks, formatting, and other stateless calculations stay in their existing
|
||||||
|
computed/composable or function boundaries.
|
||||||
|
|
||||||
|
## Constraints
|
||||||
|
|
||||||
|
Do not create classes solely to wrap Composition API refs, props, lifecycle hooks, D3 selections, or
|
||||||
|
pure mathematical helpers. Do not add strategy factories, inheritance trees, global event buses,
|
||||||
|
service locators, or duplicate prop state. Keep pure algorithms and reducer transitions as
|
||||||
|
side-effect-free functions with isolated tests.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "waveform-analysis",
|
"name": "waveform-analysis",
|
||||||
"version": "0.1.29",
|
"version": "0.1.31",
|
||||||
"main": "./dist/index.cjs",
|
"main": "./dist/index.cjs",
|
||||||
"module": "./dist/index.js",
|
"module": "./dist/index.js",
|
||||||
"types": "./dist/types/index.d.ts",
|
"types": "./dist/types/index.d.ts",
|
||||||
@@ -31,7 +31,10 @@
|
|||||||
"typecheck": "vue-tsc -b",
|
"typecheck": "vue-tsc -b",
|
||||||
"check:file-length": "node scripts/check-file-length.mjs",
|
"check:file-length": "node scripts/check-file-length.mjs",
|
||||||
"lint": "eslint . --max-warnings=0",
|
"lint": "eslint . --max-warnings=0",
|
||||||
|
"lint:oxlint": "oxlint . --deny-warnings",
|
||||||
|
"lint:all": "pnpm lint:oxlint && pnpm lint",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check .",
|
||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:coverage": "vitest run --coverage"
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
@@ -57,6 +60,7 @@
|
|||||||
"eslint-plugin-vue": "10.9.2",
|
"eslint-plugin-vue": "10.9.2",
|
||||||
"globals": "17.7.0",
|
"globals": "17.7.0",
|
||||||
"jsdom": "29.1.1",
|
"jsdom": "29.1.1",
|
||||||
|
"oxlint": "1.77.0",
|
||||||
"prettier": "3.9.5",
|
"prettier": "3.9.5",
|
||||||
"typescript": "~6.0.0",
|
"typescript": "~6.0.0",
|
||||||
"typescript-eslint": "8.64.0",
|
"typescript-eslint": "8.64.0",
|
||||||
|
|||||||
217
pnpm-lock.yaml
generated
217
pnpm-lock.yaml
generated
@@ -54,6 +54,9 @@ importers:
|
|||||||
jsdom:
|
jsdom:
|
||||||
specifier: 29.1.1
|
specifier: 29.1.1
|
||||||
version: 29.1.1
|
version: 29.1.1
|
||||||
|
oxlint:
|
||||||
|
specifier: 1.77.0
|
||||||
|
version: 1.77.0
|
||||||
prettier:
|
prettier:
|
||||||
specifier: 3.9.5
|
specifier: 3.9.5
|
||||||
version: 3.9.5
|
version: 3.9.5
|
||||||
@@ -279,6 +282,128 @@ packages:
|
|||||||
'@oxc-project/types@0.139.0':
|
'@oxc-project/types@0.139.0':
|
||||||
resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==}
|
resolution: {integrity: sha512-r9gHphtCs+1M7J0pw6Sn/hh/Wpa/iQrOOkrNAlVLF/gHq+/CJmHIWKKUUhdWjcD6CIa8idarspCsASiXCXvFUw==}
|
||||||
|
|
||||||
|
'@oxlint/binding-android-arm-eabi@1.77.0':
|
||||||
|
resolution: {integrity: sha512-E06sKWS6PiI6HRxS1wyQg22HvApt01hI7fV+T3wUk3OSbaaP4a3hYGY/MIQDmASqCiRjBdpRQYkgMkqH82cWmQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@oxlint/binding-android-arm64@1.77.0':
|
||||||
|
resolution: {integrity: sha512-NvsKz0KZxTp9cYWPLf+FXaSZwB3oO3peAjtukpOMBgse2vhQSoIIVqeO1yR0lEo/UcdZIDL18uq+kL0LzQ0ytA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [android]
|
||||||
|
|
||||||
|
'@oxlint/binding-darwin-arm64@1.77.0':
|
||||||
|
resolution: {integrity: sha512-bgjTn6nW4bQCFBvSvuHCpDD+sONvmpo4lGI4PxzMt1quBA+xYxhczk6RiCn3GZ9gY8uhaBbwhj9MdKGfu6T9DA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@oxlint/binding-darwin-x64@1.77.0':
|
||||||
|
resolution: {integrity: sha512-aotaIttH1R6j1Rwhx0M0htgeZyGtVQqYNTVEYMN/UcgHPquGA6kmk9OyuDc3a2GKUQBC+3C3GVQCcrRPMYqAFA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [darwin]
|
||||||
|
|
||||||
|
'@oxlint/binding-freebsd-x64@1.77.0':
|
||||||
|
resolution: {integrity: sha512-nNx/wta7ksRAdYvq+l4AWjXkLxEXHALhENxjj2cYbQAIR4ybaA5L+hCbE63HOmft5czQ6ks+hb8vmEAnn7YGPg==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [freebsd]
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-arm-gnueabihf@1.77.0':
|
||||||
|
resolution: {integrity: sha512-tMLLjM7xXtzXisVCzkOTXNCy9bZVId2wteNwjohlFDR/jY6WagpEDA1c1wu4xRc20Hojaxj+V6DSR7gbKxijWA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-arm-musleabihf@1.77.0':
|
||||||
|
resolution: {integrity: sha512-MiAFDFaqR0tmHTAyo0YDcZ5hyLREdYw/RQhc2R3cbT+8O3tB+zqPM2th9TTQ+Uo3jn/embS+DO+HyX9ztCPkOQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm]
|
||||||
|
os: [linux]
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-arm64-gnu@1.77.0':
|
||||||
|
resolution: {integrity: sha512-/xqQ3B16i1T4cyt/9Mn+4CpzhUXoBXp7kVpIwzOXNFLj5JmK1bIjsbSnX296Gg8A/o7oDtKWikFgBx0SLwztkw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-arm64-musl@1.77.0':
|
||||||
|
resolution: {integrity: sha512-LSbwuRKiNCenPDcbARqAZ5RfBy7gmj7vOvfJRLeCDU3gFtSxWbhv/+VTlaUqzUhNj1gFLHB8h7ALnxa/Az6z6g==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-ppc64-gnu@1.77.0':
|
||||||
|
resolution: {integrity: sha512-QWdcH31mXEUe5Nq1s0CfCpceaKjIo9uZtwDjAuL681g1axf+5x8xrg/eXWaw//4NCxYZ4V4e5Hu5tvdR+pTBlg==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [ppc64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-riscv64-gnu@1.77.0':
|
||||||
|
resolution: {integrity: sha512-GnOfYgJxbcElOiPZaDFDl406ONddwvOWk2jvAAAEjwAl4GofNoHF+/HHUIBYa6bFCArlcGPi0XjC4cU1pkgF/Q==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-riscv64-musl@1.77.0':
|
||||||
|
resolution: {integrity: sha512-AyEMTUCf0xY+hHF+IxqXFQIX0yQOIR8ykpY0lJNOw9xYqOzUX8dyZfRvlG0RfXwuQn2eonf/8NrMmDSZJjdqsA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [riscv64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-s390x-gnu@1.77.0':
|
||||||
|
resolution: {integrity: sha512-sPLzEcNvxd/oyVQ5oZo92CiHkFkpBeRop13E/P3TPY+hZfXHKCOWKI70TE2RYwMKFJDc20EMjH16L7NZICtKTw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [s390x]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-x64-gnu@1.77.0':
|
||||||
|
resolution: {integrity: sha512-1Oh2ssH2L7lwyvkdSqaMUfsGfwU2Wfvew+obBUYjRVqhpBcUpwnsPSEr1IzVi9XqkuY10geiLsNKecqaZC34Dw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [glibc]
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-x64-musl@1.77.0':
|
||||||
|
resolution: {integrity: sha512-0j/2wRgNGO+Qj/M1uu/p57h/hFTTWWcfie0ufkbabeus2s5+/QqkCflnMOwLLN5m2GsNeWp4xdl4cPa4n7QCOQ==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [linux]
|
||||||
|
libc: [musl]
|
||||||
|
|
||||||
|
'@oxlint/binding-openharmony-arm64@1.77.0':
|
||||||
|
resolution: {integrity: sha512-BJ/j54qS0usEnyDkLYURMj2iiD9h5Cyy+ppzeMSXBGRXaGRNWnj1Mw14NqWMR5E/PzdgB30OOCCzLzbRoduafw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [openharmony]
|
||||||
|
|
||||||
|
'@oxlint/binding-win32-arm64-msvc@1.77.0':
|
||||||
|
resolution: {integrity: sha512-Yh8w+g2Lpx7StrvtYkoz9JJvXjB9wxgFChFNb85nrXm/wj/XTwGWS1hve9+900HL7llrntYB3YP+y32E3tRqzA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [arm64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@oxlint/binding-win32-ia32-msvc@1.77.0':
|
||||||
|
resolution: {integrity: sha512-zja5b7+6a7UsRFgAQSrnax5vrzliEyNPLCjfXONu/vTWswaIVZGFajJZptaeRvPE4LghtFdAzVFlexTm7MVTGA==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [ia32]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
|
'@oxlint/binding-win32-x64-msvc@1.77.0':
|
||||||
|
resolution: {integrity: sha512-+teyvPDZ2RjUvo+SuCqS/UhaJl1QtdW5fWT5NJTV61V5MIuIS90Db9LixmtEGvXixyttiK62P96MSu3UlpviBw==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
cpu: [x64]
|
||||||
|
os: [win32]
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -1385,6 +1510,19 @@ packages:
|
|||||||
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
oxlint@1.77.0:
|
||||||
|
resolution: {integrity: sha512-qnGh8XJHaQ0dprrDXNQZgS0FgjI6v+V3+X8DwmaV++5Aamy6jGKfDdQ1TUvhUxtmKFAbEf4/WeO5QZX+5WSngg==}
|
||||||
|
engines: {node: ^20.19.0 || >=22.12.0}
|
||||||
|
hasBin: true
|
||||||
|
peerDependencies:
|
||||||
|
oxlint-tsgolint: '>=7.0.2001'
|
||||||
|
vite-plus: '*'
|
||||||
|
peerDependenciesMeta:
|
||||||
|
oxlint-tsgolint:
|
||||||
|
optional: true
|
||||||
|
vite-plus:
|
||||||
|
optional: true
|
||||||
|
|
||||||
p-limit@3.1.0:
|
p-limit@3.1.0:
|
||||||
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1989,6 +2127,63 @@ snapshots:
|
|||||||
|
|
||||||
'@oxc-project/types@0.139.0': {}
|
'@oxc-project/types@0.139.0': {}
|
||||||
|
|
||||||
|
'@oxlint/binding-android-arm-eabi@1.77.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-android-arm64@1.77.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-darwin-arm64@1.77.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-darwin-x64@1.77.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-freebsd-x64@1.77.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-arm-gnueabihf@1.77.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-arm-musleabihf@1.77.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-arm64-gnu@1.77.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-arm64-musl@1.77.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-ppc64-gnu@1.77.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-riscv64-gnu@1.77.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-riscv64-musl@1.77.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-s390x-gnu@1.77.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-x64-gnu@1.77.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-linux-x64-musl@1.77.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-openharmony-arm64@1.77.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-win32-arm64-msvc@1.77.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-win32-ia32-msvc@1.77.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
|
'@oxlint/binding-win32-x64-msvc@1.77.0':
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@pkgjs/parseargs@0.11.0':
|
'@pkgjs/parseargs@0.11.0':
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -3143,6 +3338,28 @@ snapshots:
|
|||||||
type-check: 0.4.0
|
type-check: 0.4.0
|
||||||
word-wrap: 1.2.5
|
word-wrap: 1.2.5
|
||||||
|
|
||||||
|
oxlint@1.77.0:
|
||||||
|
optionalDependencies:
|
||||||
|
'@oxlint/binding-android-arm-eabi': 1.77.0
|
||||||
|
'@oxlint/binding-android-arm64': 1.77.0
|
||||||
|
'@oxlint/binding-darwin-arm64': 1.77.0
|
||||||
|
'@oxlint/binding-darwin-x64': 1.77.0
|
||||||
|
'@oxlint/binding-freebsd-x64': 1.77.0
|
||||||
|
'@oxlint/binding-linux-arm-gnueabihf': 1.77.0
|
||||||
|
'@oxlint/binding-linux-arm-musleabihf': 1.77.0
|
||||||
|
'@oxlint/binding-linux-arm64-gnu': 1.77.0
|
||||||
|
'@oxlint/binding-linux-arm64-musl': 1.77.0
|
||||||
|
'@oxlint/binding-linux-ppc64-gnu': 1.77.0
|
||||||
|
'@oxlint/binding-linux-riscv64-gnu': 1.77.0
|
||||||
|
'@oxlint/binding-linux-riscv64-musl': 1.77.0
|
||||||
|
'@oxlint/binding-linux-s390x-gnu': 1.77.0
|
||||||
|
'@oxlint/binding-linux-x64-gnu': 1.77.0
|
||||||
|
'@oxlint/binding-linux-x64-musl': 1.77.0
|
||||||
|
'@oxlint/binding-openharmony-arm64': 1.77.0
|
||||||
|
'@oxlint/binding-win32-arm64-msvc': 1.77.0
|
||||||
|
'@oxlint/binding-win32-ia32-msvc': 1.77.0
|
||||||
|
'@oxlint/binding-win32-x64-msvc': 1.77.0
|
||||||
|
|
||||||
p-limit@3.1.0:
|
p-limit@3.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
yocto-queue: 0.1.0
|
yocto-queue: 0.1.0
|
||||||
|
|||||||
14
src/App.vue
14
src/App.vue
@@ -14,6 +14,7 @@ import {
|
|||||||
type WaveformLegendOrientation,
|
type WaveformLegendOrientation,
|
||||||
type WaveformLegendPosition,
|
type WaveformLegendPosition,
|
||||||
type WaveformOverlayMode,
|
type WaveformOverlayMode,
|
||||||
|
type WaveformPlotMargin,
|
||||||
type WaveformTitleOptions,
|
type WaveformTitleOptions,
|
||||||
type WaveformZoomEndPayload,
|
type WaveformZoomEndPayload,
|
||||||
type WaveformZeroLineOptions,
|
type WaveformZeroLineOptions,
|
||||||
@@ -47,6 +48,8 @@ const annotationsVisible = ref(true)
|
|||||||
const cleanView = ref(false)
|
const cleanView = ref(false)
|
||||||
const presentationMode = ref(false)
|
const presentationMode = ref(false)
|
||||||
const showTooltip = ref(true)
|
const showTooltip = ref(true)
|
||||||
|
const plotMarginTop = ref(18)
|
||||||
|
const plotMarginBottom = ref(52)
|
||||||
const zeroLineVisible = ref(false)
|
const zeroLineVisible = ref(false)
|
||||||
const zeroLineColor = ref('#98a2b3')
|
const zeroLineColor = ref('#98a2b3')
|
||||||
const zeroLineWidth = ref(1)
|
const zeroLineWidth = ref(1)
|
||||||
@@ -126,6 +129,10 @@ const zeroLine = computed<WaveformZeroLineOptions>(() => ({
|
|||||||
width: zeroLineWidth.value,
|
width: zeroLineWidth.value,
|
||||||
dash: zeroLineDash.value,
|
dash: zeroLineDash.value,
|
||||||
}))
|
}))
|
||||||
|
const plotMargin = computed<WaveformPlotMargin>(() => ({
|
||||||
|
top: plotMarginTop.value,
|
||||||
|
bottom: plotMarginBottom.value,
|
||||||
|
}))
|
||||||
|
|
||||||
const initialXValues = normalizeWaveformSeries(fullChartData).flatMap((series) =>
|
const initialXValues = normalizeWaveformSeries(fullChartData).flatMap((series) =>
|
||||||
series.points.map((point) => point.x),
|
series.points.map((point) => point.x),
|
||||||
@@ -134,9 +141,6 @@ const [initialXMinimum, initialXMaximum] = initialXValues.reduce<[number, number
|
|||||||
([minimum, maximum], value) => [Math.min(minimum, value), Math.max(maximum, value)],
|
([minimum, maximum], value) => [Math.min(minimum, value), Math.max(maximum, value)],
|
||||||
[Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY],
|
[Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY],
|
||||||
)
|
)
|
||||||
const initialXSpan = initialXMaximum - initialXMinimum
|
|
||||||
const minZoomSpan =
|
|
||||||
Number.isFinite(initialXSpan) && initialXSpan > 0 ? initialXSpan / 40 : undefined
|
|
||||||
const initialXDomainValue: [number, number] | undefined =
|
const initialXDomainValue: [number, number] | undefined =
|
||||||
Number.isFinite(initialXMinimum) && Number.isFinite(initialXMaximum)
|
Number.isFinite(initialXMinimum) && Number.isFinite(initialXMaximum)
|
||||||
? [initialXMinimum, initialXMaximum]
|
? [initialXMinimum, initialXMaximum]
|
||||||
@@ -294,6 +298,8 @@ const controlPanelModel = reactive({
|
|||||||
displayMode,
|
displayMode,
|
||||||
overlayMode,
|
overlayMode,
|
||||||
showTooltip,
|
showTooltip,
|
||||||
|
plotMarginTop,
|
||||||
|
plotMarginBottom,
|
||||||
cleanView,
|
cleanView,
|
||||||
presentationMode,
|
presentationMode,
|
||||||
selectedSeriesId,
|
selectedSeriesId,
|
||||||
@@ -344,7 +350,6 @@ const controlPanelModel = reactive({
|
|||||||
|
|
||||||
const chartModel = reactive({
|
const chartModel = reactive({
|
||||||
data: displayChartData,
|
data: displayChartData,
|
||||||
minZoomSpan,
|
|
||||||
initialXDomain,
|
initialXDomain,
|
||||||
displayMode,
|
displayMode,
|
||||||
overlayMode,
|
overlayMode,
|
||||||
@@ -360,6 +365,7 @@ const chartModel = reactive({
|
|||||||
cleanView,
|
cleanView,
|
||||||
presentationMode,
|
presentationMode,
|
||||||
showTooltip,
|
showTooltip,
|
||||||
|
plotMargin,
|
||||||
zeroLine,
|
zeroLine,
|
||||||
frameWatermarkVisible,
|
frameWatermarkVisible,
|
||||||
annotations,
|
annotations,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ const props = withDefaults(defineProps<WaveformChartProps>(), {
|
|||||||
interactionMode: undefined,
|
interactionMode: undefined,
|
||||||
grid: () => ({ rowCount: 2, columnCount: 1, showPagination: true }),
|
grid: () => ({ rowCount: 2, columnCount: 1, showPagination: true }),
|
||||||
rendering: () => ({}),
|
rendering: () => ({}),
|
||||||
|
plotMargin: () => ({}),
|
||||||
legend: () => ({ position: 'top-right', orientation: 'auto' }),
|
legend: () => ({ position: 'top-right', orientation: 'auto' }),
|
||||||
defaultHiddenSeriesIds: () => [],
|
defaultHiddenSeriesIds: () => [],
|
||||||
cleanView: false,
|
cleanView: false,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ const {
|
|||||||
overlayMode,
|
overlayMode,
|
||||||
resolvedChartLeftMargin,
|
resolvedChartLeftMargin,
|
||||||
titleAreaHeight,
|
titleAreaHeight,
|
||||||
|
resolvedPlotMargin,
|
||||||
pointerInsideChart,
|
pointerInsideChart,
|
||||||
handleNativeContextMenu,
|
handleNativeContextMenu,
|
||||||
titleAreaReserved,
|
titleAreaReserved,
|
||||||
@@ -109,7 +110,7 @@ const {
|
|||||||
{
|
{
|
||||||
'waveform-chart--clean': isCleanView,
|
'waveform-chart--clean': isCleanView,
|
||||||
'waveform-chart--presentation': isPresentationMode,
|
'waveform-chart--presentation': isPresentationMode,
|
||||||
'waveform-chart--panning': selection?.mode === 'pan',
|
'waveform-chart--panning': selection?.kind === 'pan',
|
||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
:style="containerStyle"
|
:style="containerStyle"
|
||||||
@@ -118,6 +119,8 @@ const {
|
|||||||
:data-presentation-mode="isPresentationMode"
|
:data-presentation-mode="isPresentationMode"
|
||||||
:data-overlay-mode="overlayMode"
|
:data-overlay-mode="overlayMode"
|
||||||
:data-chart-left-margin="resolvedChartLeftMargin"
|
:data-chart-left-margin="resolvedChartLeftMargin"
|
||||||
|
:data-plot-margin-top="resolvedPlotMargin.top"
|
||||||
|
:data-plot-margin-bottom="resolvedPlotMargin.bottom"
|
||||||
:data-title-area-height="titleAreaHeight"
|
:data-title-area-height="titleAreaHeight"
|
||||||
@pointerenter="pointerInsideChart = true"
|
@pointerenter="pointerInsideChart = true"
|
||||||
@pointerleave="pointerInsideChart = false"
|
@pointerleave="pointerInsideChart = false"
|
||||||
@@ -244,7 +247,7 @@ const {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<rect
|
<rect
|
||||||
v-if="selectionBox && selection?.mode === 'box'"
|
v-if="selectionBox && selection?.kind === 'box'"
|
||||||
class="waveform-chart__zoom-selection"
|
class="waveform-chart__zoom-selection"
|
||||||
:x="selectionBox.x"
|
:x="selectionBox.x"
|
||||||
:y="selectionBox.y"
|
:y="selectionBox.y"
|
||||||
@@ -287,17 +290,17 @@ const {
|
|||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
</g>
|
</g>
|
||||||
|
</g>
|
||||||
|
|
||||||
<text
|
<text
|
||||||
v-if="resolvedXLabel && !isCleanView"
|
v-if="resolvedXLabel && !isCleanView"
|
||||||
class="waveform-chart__label waveform-chart__x-label"
|
class="waveform-chart__label waveform-chart__x-label"
|
||||||
:x="innerWidth / 2"
|
:x="resolvedChartLeftMargin + innerWidth / 2"
|
||||||
:y="xAxisTitleY"
|
:y="xAxisTitleY"
|
||||||
text-anchor="middle"
|
text-anchor="middle"
|
||||||
>
|
>
|
||||||
{{ resolvedXLabel }}
|
{{ resolvedXLabel }}
|
||||||
</text>
|
</text>
|
||||||
</g>
|
|
||||||
|
|
||||||
<text
|
<text
|
||||||
v-if="hasChartArea && !hasWaveformData"
|
v-if="hasChartArea && !hasWaveformData"
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
/** 图表边距 */
|
/** 图表边距 */
|
||||||
export const margin = { top: 18, right: 24, bottom: 52, left: 64 }
|
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
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 图表最小高度(像素)
|
* 图表最小高度(像素)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -78,12 +78,14 @@ export function useWaveformChartController(
|
|||||||
},
|
},
|
||||||
{ deep: true },
|
{ deep: true },
|
||||||
)
|
)
|
||||||
const selection = ref<ViewportSelectionState | null>(null)
|
const selection = shallowRef<ViewportSelectionState | null>(null)
|
||||||
const spacePressed = ref(false)
|
const spacePressed = ref(false)
|
||||||
const pointerInsideChart = ref(false)
|
const pointerInsideChart = ref(false)
|
||||||
|
let handleBeforeDataReferenceChange: () => void = () => undefined
|
||||||
let handleDataReferenceChange: () => void = () => undefined
|
let handleDataReferenceChange: () => void = () => undefined
|
||||||
const preparedSeries = usePreparedWaveformSeries(
|
const preparedSeries = usePreparedWaveformSeries(
|
||||||
() => props.data,
|
() => props.data,
|
||||||
|
() => handleBeforeDataReferenceChange(),
|
||||||
() => handleDataReferenceChange(),
|
() => handleDataReferenceChange(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -127,6 +129,7 @@ export function useWaveformChartController(
|
|||||||
const {
|
const {
|
||||||
chartSeries,
|
chartSeries,
|
||||||
chartTracks,
|
chartTracks,
|
||||||
|
trackLayouts,
|
||||||
gridOptions,
|
gridOptions,
|
||||||
pageCount,
|
pageCount,
|
||||||
pagedTracks,
|
pagedTracks,
|
||||||
@@ -138,7 +141,6 @@ export function useWaveformChartController(
|
|||||||
initialXDomain,
|
initialXDomain,
|
||||||
sharedZoomDomain,
|
sharedZoomDomain,
|
||||||
resolveInitialTrackDomain,
|
resolveInitialTrackDomain,
|
||||||
trackLayouts,
|
|
||||||
annotationLayoutsForTrack,
|
annotationLayoutsForTrack,
|
||||||
resolveSeriesYScale,
|
resolveSeriesYScale,
|
||||||
} = layout
|
} = layout
|
||||||
@@ -254,6 +256,7 @@ export function useWaveformChartController(
|
|||||||
gridOptions,
|
gridOptions,
|
||||||
chartSeries,
|
chartSeries,
|
||||||
chartTracks,
|
chartTracks,
|
||||||
|
trackLayouts,
|
||||||
innerWidth,
|
innerWidth,
|
||||||
innerHeight,
|
innerHeight,
|
||||||
activeInteractionMode,
|
activeInteractionMode,
|
||||||
@@ -261,6 +264,7 @@ export function useWaveformChartController(
|
|||||||
internalHiddenSeriesIds,
|
internalHiddenSeriesIds,
|
||||||
independentTransforms,
|
independentTransforms,
|
||||||
independentYDomains,
|
independentYDomains,
|
||||||
|
resolveInitialTrackDomain,
|
||||||
annotationInteraction,
|
annotationInteraction,
|
||||||
editorSeriesOptions,
|
editorSeriesOptions,
|
||||||
isPresentationMode,
|
isPresentationMode,
|
||||||
@@ -272,6 +276,7 @@ export function useWaveformChartController(
|
|||||||
cancelPendingHover: hover.cancelPendingHover,
|
cancelPendingHover: hover.cancelPendingHover,
|
||||||
clearZoomBindings: zoom.clearZoomBindings,
|
clearZoomBindings: zoom.clearZoomBindings,
|
||||||
})
|
})
|
||||||
|
handleBeforeDataReferenceChange = lifecycle.handleBeforeDataReferenceChange
|
||||||
handleDataReferenceChange = lifecycle.handleDataReferenceChange
|
handleDataReferenceChange = lifecycle.handleDataReferenceChange
|
||||||
|
|
||||||
return reactive({
|
return reactive({
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import {
|
|||||||
|
|
||||||
import type { AnnotationSeriesCandidate } from '../annotation'
|
import type { AnnotationSeriesCandidate } from '../annotation'
|
||||||
import type { useWaveformAnnotationInteraction } from '../annotation'
|
import type { useWaveformAnnotationInteraction } from '../annotation'
|
||||||
import type { DisplaySeries, DisplayTrack } from './types'
|
import type { DisplaySeries, DisplayTrack, TrackLayout } from './types'
|
||||||
import type { ResolvedWaveformChartProps, WaveformChartEmit } from './waveformChartTypes'
|
import type { ResolvedWaveformChartProps, WaveformChartEmit } from './waveformChartTypes'
|
||||||
|
import { constrainZoomDomain, transformForDomain } from '../interaction/zoomConstraints'
|
||||||
|
|
||||||
interface LifecycleContext {
|
interface LifecycleContext {
|
||||||
props: ResolvedWaveformChartProps
|
props: ResolvedWaveformChartProps
|
||||||
@@ -35,6 +36,7 @@ interface LifecycleContext {
|
|||||||
gridOptions: ComputedRef<{ rowCount: number; columnCount: number }>
|
gridOptions: ComputedRef<{ rowCount: number; columnCount: number }>
|
||||||
chartSeries: ComputedRef<DisplaySeries[]>
|
chartSeries: ComputedRef<DisplaySeries[]>
|
||||||
chartTracks: ComputedRef<DisplayTrack[]>
|
chartTracks: ComputedRef<DisplayTrack[]>
|
||||||
|
trackLayouts: ComputedRef<TrackLayout[]>
|
||||||
innerWidth: ComputedRef<number>
|
innerWidth: ComputedRef<number>
|
||||||
innerHeight: ComputedRef<number>
|
innerHeight: ComputedRef<number>
|
||||||
activeInteractionMode: ComputedRef<string | undefined>
|
activeInteractionMode: ComputedRef<string | undefined>
|
||||||
@@ -42,6 +44,7 @@ interface LifecycleContext {
|
|||||||
internalHiddenSeriesIds: Ref<Set<string>>
|
internalHiddenSeriesIds: Ref<Set<string>>
|
||||||
independentTransforms: ShallowRef<ZoomTransform[]>
|
independentTransforms: ShallowRef<ZoomTransform[]>
|
||||||
independentYDomains: Ref<Record<number, [number, number]>>
|
independentYDomains: Ref<Record<number, [number, number]>>
|
||||||
|
resolveInitialTrackDomain: (track: TrackLayout) => [number, number]
|
||||||
annotationInteraction: ReturnType<typeof useWaveformAnnotationInteraction>
|
annotationInteraction: ReturnType<typeof useWaveformAnnotationInteraction>
|
||||||
editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
|
editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
|
||||||
isPresentationMode: ComputedRef<boolean>
|
isPresentationMode: ComputedRef<boolean>
|
||||||
@@ -87,6 +90,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
gridOptions,
|
gridOptions,
|
||||||
chartSeries,
|
chartSeries,
|
||||||
chartTracks,
|
chartTracks,
|
||||||
|
trackLayouts,
|
||||||
innerWidth,
|
innerWidth,
|
||||||
innerHeight,
|
innerHeight,
|
||||||
activeInteractionMode,
|
activeInteractionMode,
|
||||||
@@ -94,6 +98,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
internalHiddenSeriesIds,
|
internalHiddenSeriesIds,
|
||||||
independentTransforms,
|
independentTransforms,
|
||||||
independentYDomains,
|
independentYDomains,
|
||||||
|
resolveInitialTrackDomain,
|
||||||
annotationInteraction,
|
annotationInteraction,
|
||||||
editorSeriesOptions,
|
editorSeriesOptions,
|
||||||
isPresentationMode,
|
isPresentationMode,
|
||||||
@@ -139,6 +144,27 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
emit('page-change', nextPage, pageCount.value)
|
emit('page-change', nextPage, pageCount.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pendingIndependentXDomains: Array<[number, number] | undefined> | undefined
|
||||||
|
|
||||||
|
function handleBeforeDataReferenceChange() {
|
||||||
|
if (props.displayMode !== 'independent') return
|
||||||
|
pendingIndependentXDomains = trackLayouts.value.map((track) => {
|
||||||
|
const configuredDomain =
|
||||||
|
props.initialXDomains?.[track.series.trackId ?? track.series.id] ??
|
||||||
|
props.initialXDomains?.[track.series.id] ??
|
||||||
|
props.initialXDomain
|
||||||
|
if (
|
||||||
|
!configuredDomain ||
|
||||||
|
!Number.isFinite(configuredDomain[0]) ||
|
||||||
|
!Number.isFinite(configuredDomain[1]) ||
|
||||||
|
configuredDomain[0] === configuredDomain[1]
|
||||||
|
) {
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
return track.xScale.domain() as [number, number]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
function handleDataReferenceChange() {
|
function handleDataReferenceChange() {
|
||||||
if (props.displayMode === 'independent') {
|
if (props.displayMode === 'independent') {
|
||||||
const currentTransforms = independentTransforms.value
|
const currentTransforms = independentTransforms.value
|
||||||
@@ -148,7 +174,24 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
}
|
}
|
||||||
clearHover()
|
clearHover()
|
||||||
editorSeriesOptions.value = []
|
editorSeriesOptions.value = []
|
||||||
|
void nextTick(() => {
|
||||||
|
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)
|
void nextTick(configureZoom)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingIndependentXDomains = undefined
|
||||||
|
configureZoom()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -158,6 +201,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
() => props.zoomable,
|
() => props.zoomable,
|
||||||
isPresentationMode,
|
isPresentationMode,
|
||||||
() => props.minZoomSpan,
|
() => props.minZoomSpan,
|
||||||
|
() => props.minVisiblePoints,
|
||||||
() => props.initialXDomain,
|
() => props.initialXDomain,
|
||||||
() => props.initialXDomains,
|
() => props.initialXDomains,
|
||||||
() => props.displayMode,
|
() => props.displayMode,
|
||||||
@@ -324,5 +368,5 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
editorSeriesOptions.value = []
|
editorSeriesOptions.value = []
|
||||||
})
|
})
|
||||||
|
|
||||||
return { goToPage, handleDataReferenceChange }
|
return { goToPage, handleBeforeDataReferenceChange, handleDataReferenceChange }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,9 +62,14 @@ export function prepareWaveformSeries(data: WaveformData): PreparedWaveformSerie
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function usePreparedWaveformSeries(data: () => WaveformData, onDataChange: () => void) {
|
export function usePreparedWaveformSeries(
|
||||||
|
data: () => WaveformData,
|
||||||
|
onBeforeDataChange: () => void,
|
||||||
|
onDataChange: () => void,
|
||||||
|
) {
|
||||||
const preparedSeries = shallowRef<PreparedWaveformSeries[]>(prepareWaveformSeries(data()))
|
const preparedSeries = shallowRef<PreparedWaveformSeries[]>(prepareWaveformSeries(data()))
|
||||||
watch(data, (nextData) => {
|
watch(data, (nextData) => {
|
||||||
|
onBeforeDataChange()
|
||||||
preparedSeries.value = prepareWaveformSeries(nextData)
|
preparedSeries.value = prepareWaveformSeries(nextData)
|
||||||
onDataChange()
|
onDataChange()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import {
|
|||||||
normalizeGridOptions,
|
normalizeGridOptions,
|
||||||
paginateSeries,
|
paginateSeries,
|
||||||
resolveGridCellGeometry,
|
resolveGridCellGeometry,
|
||||||
X_AXIS_BAND,
|
|
||||||
} from './grid'
|
} from './grid'
|
||||||
import {
|
import {
|
||||||
buildTrackLayouts,
|
buildTrackLayouts,
|
||||||
@@ -316,7 +315,6 @@ export function useWaveformLayout(context: LayoutContext) {
|
|||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
)
|
)
|
||||||
const xAxisTitleY = computed(() => innerHeight.value + X_AXIS_BAND + 10)
|
|
||||||
const editorSeries = computed<AnnotationSeriesInfo | undefined>(() => {
|
const editorSeries = computed<AnnotationSeriesInfo | undefined>(() => {
|
||||||
const seriesId = annotationInteraction.editorDraft.value?.annotation.seriesId
|
const seriesId = annotationInteraction.editorDraft.value?.annotation.seriesId
|
||||||
const series = chartSeries.value.find((item) => item.id === seriesId)
|
const series = chartSeries.value.find((item) => item.id === seriesId)
|
||||||
@@ -360,7 +358,6 @@ export function useWaveformLayout(context: LayoutContext) {
|
|||||||
resolveSeriesYScale,
|
resolveSeriesYScale,
|
||||||
annotationTrackLayouts,
|
annotationTrackLayouts,
|
||||||
renderedAnnotations,
|
renderedAnnotations,
|
||||||
xAxisTitleY,
|
|
||||||
editorSeries,
|
editorSeries,
|
||||||
resolveFrameNumber,
|
resolveFrameNumber,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
TITLE_CHAR_WIDTH_RATIO,
|
TITLE_CHAR_WIDTH_RATIO,
|
||||||
TITLE_DEFAULT_FONT_SIZE,
|
TITLE_DEFAULT_FONT_SIZE,
|
||||||
TITLE_LINE_HEIGHT,
|
TITLE_LINE_HEIGHT,
|
||||||
|
X_AXIS_TITLE_BOTTOM_OFFSET,
|
||||||
ZERO_LINE_DEFAULTS,
|
ZERO_LINE_DEFAULTS,
|
||||||
} from './constants'
|
} from './constants'
|
||||||
import { calculateRotatedTitleLayout, TITLE_AREA_HORIZONTAL_PADDING } from './title'
|
import { calculateRotatedTitleLayout, TITLE_AREA_HORIZONTAL_PADDING } from './title'
|
||||||
@@ -144,11 +145,23 @@ export function useWaveformPresentation(context: PresentationContext) {
|
|||||||
const titleAreaHeight = computed(() =>
|
const titleAreaHeight = computed(() =>
|
||||||
titleAreaReserved.value ? titleLayout.value.areaHeight : 0,
|
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(() =>
|
const drawingHeight = computed(() =>
|
||||||
Math.max(0, chartHeight.value - titleAreaHeight.value - paginationBandHeight.value),
|
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>(() => ({
|
const titleAreaStyle = computed<CSSProperties>(() => ({
|
||||||
height: `${titleAreaHeight.value}px`,
|
height: `${titleAreaHeight.value}px`,
|
||||||
justifyContent:
|
justifyContent:
|
||||||
@@ -193,8 +206,10 @@ export function useWaveformPresentation(context: PresentationContext) {
|
|||||||
titleMeasureStyle,
|
titleMeasureStyle,
|
||||||
titleLayout,
|
titleLayout,
|
||||||
titleAreaHeight,
|
titleAreaHeight,
|
||||||
|
resolvedPlotMargin,
|
||||||
chartTopMargin,
|
chartTopMargin,
|
||||||
drawingHeight,
|
drawingHeight,
|
||||||
|
xAxisTitleY,
|
||||||
innerHeight,
|
innerHeight,
|
||||||
titleAreaStyle,
|
titleAreaStyle,
|
||||||
titleVisualStyle,
|
titleVisualStyle,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import type {
|
|||||||
WaveformInteractionMode,
|
WaveformInteractionMode,
|
||||||
WaveformLegendOptions,
|
WaveformLegendOptions,
|
||||||
WaveformOverlayMode,
|
WaveformOverlayMode,
|
||||||
|
WaveformPlotMargin,
|
||||||
WaveformPoint,
|
WaveformPoint,
|
||||||
WaveformRenderingOptions,
|
WaveformRenderingOptions,
|
||||||
WaveformTitleOptions,
|
WaveformTitleOptions,
|
||||||
@@ -42,6 +43,7 @@ export interface WaveformChartProps {
|
|||||||
interactionMode?: WaveformInteractionMode
|
interactionMode?: WaveformInteractionMode
|
||||||
grid?: WaveformGridOptions
|
grid?: WaveformGridOptions
|
||||||
rendering?: WaveformRenderingOptions
|
rendering?: WaveformRenderingOptions
|
||||||
|
plotMargin?: WaveformPlotMargin
|
||||||
title?: WaveformTitleOptions
|
title?: WaveformTitleOptions
|
||||||
legend?: WaveformLegendOptions
|
legend?: WaveformLegendOptions
|
||||||
hiddenSeriesIds?: string[]
|
hiddenSeriesIds?: string[]
|
||||||
@@ -65,6 +67,7 @@ type DefaultedProp =
|
|||||||
| 'annotationsVisible'
|
| 'annotationsVisible'
|
||||||
| 'grid'
|
| 'grid'
|
||||||
| 'rendering'
|
| 'rendering'
|
||||||
|
| 'plotMargin'
|
||||||
| 'legend'
|
| 'legend'
|
||||||
| 'defaultHiddenSeriesIds'
|
| 'defaultHiddenSeriesIds'
|
||||||
| 'cleanView'
|
| 'cleanView'
|
||||||
@@ -92,16 +95,17 @@ export interface WaveformChartEmit {
|
|||||||
(event: 'page-change', page: number, pageCount: number): void
|
(event: 'page-change', page: number, pageCount: number): void
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ViewportSelectionState {
|
interface ViewportSelectionBase {
|
||||||
trackIndex: number
|
trackIndex: number
|
||||||
independent: boolean
|
independent: boolean
|
||||||
overlay: SVGRectElement
|
|
||||||
startX: number
|
startX: number
|
||||||
startY: number
|
startY: number
|
||||||
currentX: number
|
currentX: number
|
||||||
currentY: number
|
currentY: number
|
||||||
pointerId: number
|
pointerId: number
|
||||||
mode: 'box' | 'pan'
|
|
||||||
xDomain: [number, number]
|
xDomain: [number, number]
|
||||||
yDomains: Record<string, [number, number]>
|
yDomains: Record<string, [number, number]>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ViewportSelectionState =
|
||||||
|
(ViewportSelectionBase & { kind: 'box' }) | (ViewportSelectionBase & { kind: 'pan' })
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ export type {
|
|||||||
WaveformAnnotationStyle,
|
WaveformAnnotationStyle,
|
||||||
WaveformAnnotation,
|
WaveformAnnotation,
|
||||||
WaveformRenderingOptions,
|
WaveformRenderingOptions,
|
||||||
|
WaveformPlotMargin,
|
||||||
WaveformTitleTextStyle,
|
WaveformTitleTextStyle,
|
||||||
WaveformTitleOptions,
|
WaveformTitleOptions,
|
||||||
WaveformLegendPosition,
|
WaveformLegendPosition,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type {
|
|||||||
WaveformAnnotationStyle,
|
WaveformAnnotationStyle,
|
||||||
WaveformAnnotation,
|
WaveformAnnotation,
|
||||||
WaveformRenderingOptions,
|
WaveformRenderingOptions,
|
||||||
|
WaveformPlotMargin,
|
||||||
WaveformTitleTextStyle,
|
WaveformTitleTextStyle,
|
||||||
WaveformTitleOptions,
|
WaveformTitleOptions,
|
||||||
WaveformLegendPosition,
|
WaveformLegendPosition,
|
||||||
|
|||||||
@@ -38,9 +38,48 @@ describe('WaveformTooltip', () => {
|
|||||||
const tooltip = mountTooltip(100, 200).get('.waveform-tooltip')
|
const tooltip = mountTooltip(100, 200).get('.waveform-tooltip')
|
||||||
|
|
||||||
expect(tooltip.attributes('style')).toContain('left: 8px')
|
expect(tooltip.attributes('style')).toContain('left: 8px')
|
||||||
|
expect(tooltip.attributes('style')).toContain('max-width: 184px')
|
||||||
expect(tooltip.attributes('style')).not.toContain('right:')
|
expect(tooltip.attributes('style')).not.toContain('right:')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('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', () => {
|
it('shows resolved asymmetric errors beside the hovered value', () => {
|
||||||
const pointWithErrors = { x: 1, y: 12, error: 1, upperError: 2 }
|
const pointWithErrors = { x: 1, y: 12, error: 1, upperError: 2 }
|
||||||
const wrapper = mount(WaveformTooltip, {
|
const wrapper = mount(WaveformTooltip, {
|
||||||
|
|||||||
@@ -33,24 +33,68 @@ const props = defineProps<Props>()
|
|||||||
|
|
||||||
const tooltipGap = 12
|
const tooltipGap = 12
|
||||||
const containerPadding = 8
|
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(() => {
|
const tooltipStyle = computed(() => {
|
||||||
if (!props.visible || !props.hoveredPoint) return { display: 'none' }
|
if (!props.visible || !props.hoveredPoint) return { display: 'none' }
|
||||||
|
|
||||||
const estimatedHeight = 44 + props.seriesPoints.length * 22
|
|
||||||
const rightPlacement = props.position.x + tooltipGap
|
const rightPlacement = props.position.x + tooltipGap
|
||||||
const leftPlacement = props.position.x - tooltipGap - tooltipMaxWidth
|
const leftPlacement = props.position.x - tooltipGap - tooltipPlacementWidth
|
||||||
|
const rightAvailableWidth = props.containerWidth - containerPadding - rightPlacement
|
||||||
|
const leftAvailableWidth = props.position.x - tooltipGap - containerPadding
|
||||||
|
const availableWidth = Math.max(
|
||||||
|
1,
|
||||||
|
Math.min(tooltipMaxWidth, props.containerWidth - containerPadding * 2),
|
||||||
|
)
|
||||||
const horizontalStyle =
|
const horizontalStyle =
|
||||||
rightPlacement + tooltipMaxWidth <= props.containerWidth - containerPadding
|
rightPlacement + tooltipPlacementWidth <= props.containerWidth - containerPadding
|
||||||
? { left: `${rightPlacement}px` }
|
? {
|
||||||
|
left: `${rightPlacement}px`,
|
||||||
|
maxWidth: `${Math.min(tooltipMaxWidth, rightAvailableWidth)}px`,
|
||||||
|
}
|
||||||
: leftPlacement >= containerPadding
|
: leftPlacement >= containerPadding
|
||||||
? { right: `${props.containerWidth - props.position.x + tooltipGap}px` }
|
? {
|
||||||
: { left: `${containerPadding}px` }
|
right: `${props.containerWidth - props.position.x + tooltipGap}px`,
|
||||||
|
maxWidth: `${Math.min(tooltipMaxWidth, leftAvailableWidth)}px`,
|
||||||
|
}
|
||||||
|
: { left: `${containerPadding}px`, maxWidth: `${availableWidth}px` }
|
||||||
|
|
||||||
|
const maxWidth = Number.parseFloat(horizontalStyle.maxWidth)
|
||||||
|
const timeText = `${props.timeUnit}: ${formatTooltipTime(props.hoveredPoint.x, props.timeUnit)}`
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...horizontalStyle,
|
...horizontalStyle,
|
||||||
top: `${Math.max(8, Math.min(props.position.y - 18, props.containerHeight - estimatedHeight - 8))}px`,
|
top: `${Math.max(
|
||||||
|
8,
|
||||||
|
Math.min(
|
||||||
|
props.position.y - 18,
|
||||||
|
props.containerHeight - estimateTooltipHeight(maxWidth, timeText) - 8,
|
||||||
|
),
|
||||||
|
)}px`,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -76,6 +120,7 @@ function formatError(point: WaveformPoint): string | null {
|
|||||||
class="waveform-tooltip__series waveform-chart__tooltip-series"
|
class="waveform-tooltip__series waveform-chart__tooltip-series"
|
||||||
>
|
>
|
||||||
<i :style="{ backgroundColor: seriesPoint.color }" />
|
<i :style="{ backgroundColor: seriesPoint.color }" />
|
||||||
|
<span class="waveform-tooltip__series-content">
|
||||||
<strong v-if="seriesPoint.name">{{ seriesPoint.name }}:</strong>
|
<strong v-if="seriesPoint.name">{{ seriesPoint.name }}:</strong>
|
||||||
<span class="waveform-tooltip__value">
|
<span class="waveform-tooltip__value">
|
||||||
{{ formatTooltipNumber(seriesPoint.point.y)
|
{{ formatTooltipNumber(seriesPoint.point.y)
|
||||||
@@ -83,6 +128,7 @@ function formatError(point: WaveformPoint): string | null {
|
|||||||
<small v-if="formatError(seriesPoint.point)">{{ formatError(seriesPoint.point) }}</small>
|
<small v-if="formatError(seriesPoint.point)">{{ formatError(seriesPoint.point) }}</small>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -93,8 +139,8 @@ function formatError(point: WaveformPoint): string | null {
|
|||||||
z-index: 2;
|
z-index: 2;
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 3px;
|
gap: 3px;
|
||||||
min-width: 180px;
|
width: max-content;
|
||||||
max-width: 238px;
|
max-width: min(320px, calc(100% - 16px));
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
color: #333;
|
color: #333;
|
||||||
font:
|
font:
|
||||||
@@ -116,30 +162,34 @@ function formatError(point: WaveformPoint): string | null {
|
|||||||
|
|
||||||
.waveform-tooltip__series {
|
.waveform-tooltip__series {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 8px minmax(0, 1fr) auto;
|
grid-template-columns: 8px minmax(0, 1fr);
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
align-items: center;
|
align-items: start;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waveform-tooltip__series i {
|
.waveform-tooltip__series i {
|
||||||
|
flex: 0 0 auto;
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.waveform-tooltip__series-content {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: normal;
|
||||||
|
}
|
||||||
|
|
||||||
.waveform-tooltip__series strong {
|
.waveform-tooltip__series strong {
|
||||||
overflow: hidden;
|
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.waveform-tooltip__value {
|
.waveform-tooltip__value {
|
||||||
white-space: nowrap;
|
overflow-wrap: anywhere;
|
||||||
}
|
}
|
||||||
|
|
||||||
.waveform-tooltip__series small {
|
.waveform-tooltip__series small {
|
||||||
color: #667085;
|
color: #667085;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
10
src/components/interaction/pointerCapture.ts
Normal file
10
src/components/interaction/pointerCapture.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export function tryReleasePointerCapture(
|
||||||
|
target: SVGRectElement | null | undefined,
|
||||||
|
pointerId: number,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
target?.releasePointerCapture?.(pointerId)
|
||||||
|
} catch {
|
||||||
|
// The target may already be detached or have released the pointer.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -136,7 +136,7 @@ export function useWaveformHover(context: HoverContext) {
|
|||||||
}
|
}
|
||||||
const handleSharedPointerMove = (event: PointerEvent) => {
|
const handleSharedPointerMove = (event: PointerEvent) => {
|
||||||
if (isPresentationMode.value) return
|
if (isPresentationMode.value) return
|
||||||
if (selection.value?.overlay === event.currentTarget) {
|
if (selection.value && !selection.value.independent) {
|
||||||
updateViewportDrag(event)
|
updateViewportDrag(event)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { pointer, scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
|
import { pointer, zoomIdentity, type ZoomTransform } from 'd3'
|
||||||
import { computed, nextTick, type ComputedRef, type Ref, type ShallowRef } from 'vue'
|
import { computed, nextTick, shallowRef, type ComputedRef, type Ref, type ShallowRef } from 'vue'
|
||||||
|
|
||||||
import { MINIMUM_SELECTION_SIZE } from '../core/constants'
|
import { MINIMUM_SELECTION_SIZE } from '../core/constants'
|
||||||
import type { DisplayTrack, TrackLayout } from '../core/types'
|
import type { DisplayTrack, TrackLayout } from '../core/types'
|
||||||
import { hasFixedYDomainForTrack } from '../core/yDomain'
|
import { hasFixedYDomainForTrack } from '../core/yDomain'
|
||||||
@@ -10,7 +9,9 @@ import type {
|
|||||||
WaveformChartEmit,
|
WaveformChartEmit,
|
||||||
} from '../core/waveformChartTypes'
|
} from '../core/waveformChartTypes'
|
||||||
import type { AnnotationSeriesCandidate } from '../annotation'
|
import type { AnnotationSeriesCandidate } from '../annotation'
|
||||||
|
import { tryReleasePointerCapture } from './pointerCapture'
|
||||||
|
import { transitionViewportInteraction } from './viewportInteractionState'
|
||||||
|
import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
|
||||||
interface ViewportContext {
|
interface ViewportContext {
|
||||||
props: ResolvedWaveformChartProps
|
props: ResolvedWaveformChartProps
|
||||||
emit: WaveformChartEmit
|
emit: WaveformChartEmit
|
||||||
@@ -37,7 +38,6 @@ interface ViewportContext {
|
|||||||
clearHover: () => void
|
clearHover: () => void
|
||||||
resolveTrackAtPointer: (pointerX: number, pointerY: number) => TrackLayout | undefined
|
resolveTrackAtPointer: (pointerX: number, pointerY: number) => TrackLayout | undefined
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useWaveformViewport(context: ViewportContext) {
|
export function useWaveformViewport(context: ViewportContext) {
|
||||||
const {
|
const {
|
||||||
props,
|
props,
|
||||||
@@ -65,6 +65,23 @@ export function useWaveformViewport(context: ViewportContext) {
|
|||||||
clearHover,
|
clearHover,
|
||||||
resolveTrackAtPointer,
|
resolveTrackAtPointer,
|
||||||
} = context
|
} = context
|
||||||
|
const activeOverlay = shallowRef<SVGRectElement>()
|
||||||
|
const releasePointerCapture = (pointerId: number, event?: PointerEvent) => {
|
||||||
|
const overlay = activeOverlay.value
|
||||||
|
const eventTarget = event?.currentTarget as SVGRectElement | null
|
||||||
|
tryReleasePointerCapture(overlay, pointerId)
|
||||||
|
if (eventTarget && eventTarget !== overlay) tryReleasePointerCapture(eventTarget, pointerId)
|
||||||
|
}
|
||||||
|
const cleanupViewportDrag = (pointerId: number, event?: PointerEvent) => {
|
||||||
|
const active = selection.value
|
||||||
|
if (!active || active.pointerId !== pointerId) return
|
||||||
|
releasePointerCapture(pointerId, event)
|
||||||
|
selection.value = transitionViewportInteraction(selection.value, {
|
||||||
|
type: 'cancel',
|
||||||
|
pointerId,
|
||||||
|
}).state
|
||||||
|
activeOverlay.value = undefined
|
||||||
|
}
|
||||||
const selectionBox = computed(() => {
|
const selectionBox = computed(() => {
|
||||||
const active = selection.value
|
const active = selection.value
|
||||||
if (!active) return null
|
if (!active) return null
|
||||||
@@ -76,48 +93,6 @@ export function useWaveformViewport(context: ViewportContext) {
|
|||||||
height: Math.abs(active.currentY - active.startY),
|
height: Math.abs(active.currentY - active.startY),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const transformForDomain = (
|
|
||||||
domain: [number, number],
|
|
||||||
baseDomain: [number, number],
|
|
||||||
width: number,
|
|
||||||
): ZoomTransform => {
|
|
||||||
const baseSpan = baseDomain[1] - baseDomain[0]
|
|
||||||
const span = domain[1] - domain[0]
|
|
||||||
if (!Number.isFinite(baseSpan) || !Number.isFinite(span) || baseSpan <= 0 || span <= 0) {
|
|
||||||
return zoomIdentity
|
|
||||||
}
|
|
||||||
const scale = baseSpan / span
|
|
||||||
const baseScale = scaleLinear(baseDomain, [0, width])
|
|
||||||
return zoomIdentity.translate(-scale * baseScale(domain[0]), 0).scale(scale)
|
|
||||||
}
|
|
||||||
const resolveMinimumZoomSpan = (boundary: [number, number]): number => {
|
|
||||||
const boundarySpan = Math.abs(boundary[1] - boundary[0])
|
|
||||||
if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return 0
|
|
||||||
const configured = props.minZoomSpan
|
|
||||||
if (Number.isFinite(configured) && (configured ?? 0) > 0) {
|
|
||||||
return Math.min(boundarySpan, configured as number)
|
|
||||||
}
|
|
||||||
return boundarySpan / 40
|
|
||||||
}
|
|
||||||
const constrainZoomDomain = (
|
|
||||||
domain: [number, number],
|
|
||||||
boundary: [number, number],
|
|
||||||
): [number, number] => {
|
|
||||||
const normalizedBoundary: [number, number] =
|
|
||||||
boundary[0] <= boundary[1] ? [...boundary] : [boundary[1], boundary[0]]
|
|
||||||
const boundarySpan = normalizedBoundary[1] - normalizedBoundary[0]
|
|
||||||
if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return normalizedBoundary
|
|
||||||
const requestedStart = Math.min(domain[0], domain[1])
|
|
||||||
const requestedEnd = Math.max(domain[0], domain[1])
|
|
||||||
const minimumSpan = resolveMinimumZoomSpan(normalizedBoundary)
|
|
||||||
const span = Math.max(minimumSpan, Math.min(boundarySpan, requestedEnd - requestedStart))
|
|
||||||
const center = (requestedStart + requestedEnd) / 2
|
|
||||||
const start = Math.max(
|
|
||||||
normalizedBoundary[0],
|
|
||||||
Math.min(center - span / 2, normalizedBoundary[1] - span),
|
|
||||||
)
|
|
||||||
return [start, start + span]
|
|
||||||
}
|
|
||||||
const clampDomain = (domain: [number, number], boundary: [number, number]): [number, number] => {
|
const clampDomain = (domain: [number, number], boundary: [number, number]): [number, number] => {
|
||||||
const span = domain[1] - domain[0]
|
const span = domain[1] - domain[0]
|
||||||
const boundarySpan = boundary[1] - boundary[0]
|
const boundarySpan = boundary[1] - boundary[0]
|
||||||
@@ -143,19 +118,22 @@ export function useWaveformViewport(context: ViewportContext) {
|
|||||||
const [rawX, rawY] = pointer(event, overlay)
|
const [rawX, rawY] = pointer(event, overlay)
|
||||||
const x = Math.max(0, Math.min(independent ? track.width : innerWidth.value, rawX))
|
const x = Math.max(0, Math.min(independent ? track.width : innerWidth.value, rawX))
|
||||||
const y = Math.max(0, Math.min(independent ? track.height : innerHeight.value, rawY))
|
const y = Math.max(0, Math.min(independent ? track.height : innerHeight.value, rawY))
|
||||||
selection.value = {
|
const started = transitionViewportInteraction(selection.value, {
|
||||||
|
type: 'begin',
|
||||||
|
gesture: {
|
||||||
trackIndex,
|
trackIndex,
|
||||||
independent,
|
independent,
|
||||||
overlay,
|
|
||||||
startX: x,
|
startX: x,
|
||||||
startY: y,
|
startY: y,
|
||||||
currentX: x,
|
|
||||||
currentY: y,
|
|
||||||
pointerId: event.pointerId,
|
pointerId: event.pointerId,
|
||||||
mode: panRequested ? 'pan' : 'box',
|
kind: panRequested ? 'pan' : 'box',
|
||||||
xDomain: track.xScale.domain() as [number, number],
|
xDomain: track.xScale.domain() as [number, number],
|
||||||
yDomains: currentYDomains(),
|
yDomains: currentYDomains(),
|
||||||
}
|
},
|
||||||
|
})
|
||||||
|
if (!started.accepted) return
|
||||||
|
selection.value = started.state
|
||||||
|
activeOverlay.value = overlay
|
||||||
overlay.setPointerCapture?.(event.pointerId)
|
overlay.setPointerCapture?.(event.pointerId)
|
||||||
clearHover()
|
clearHover()
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
@@ -213,27 +191,34 @@ export function useWaveformViewport(context: ViewportContext) {
|
|||||||
const updateViewportDrag = (event: PointerEvent) => {
|
const updateViewportDrag = (event: PointerEvent) => {
|
||||||
if (isPresentationMode.value) return
|
if (isPresentationMode.value) return
|
||||||
const active = selection.value
|
const active = selection.value
|
||||||
if (!active || event.pointerId !== active.pointerId) return
|
const overlay = activeOverlay.value
|
||||||
|
if (!active || !overlay || event.pointerId !== active.pointerId) return
|
||||||
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
|
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
|
||||||
if (!track) return
|
if (!track) return
|
||||||
const [rawX, rawY] = pointer(event, active.overlay)
|
const [rawX, rawY] = pointer(event, overlay)
|
||||||
active.currentX = Math.max(
|
const currentX = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(active.independent ? track.width : innerWidth.value, rawX),
|
Math.min(active.independent ? track.width : innerWidth.value, rawX),
|
||||||
)
|
)
|
||||||
active.currentY = Math.max(
|
const currentY = Math.max(
|
||||||
0,
|
0,
|
||||||
Math.min(active.independent ? track.height : innerHeight.value, rawY),
|
Math.min(active.independent ? track.height : innerHeight.value, rawY),
|
||||||
)
|
)
|
||||||
selection.value = { ...active }
|
const transition = transitionViewportInteraction(selection.value, {
|
||||||
if (active.mode === 'pan') applyPan(active, track)
|
type: 'move',
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
position: { currentX, currentY },
|
||||||
|
})
|
||||||
|
if (!transition.accepted || !transition.state) return
|
||||||
|
const next = transition.state
|
||||||
|
selection.value = next
|
||||||
|
if (next.kind === 'pan') applyPan(next, track)
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
}
|
}
|
||||||
const cancelViewportDrag = (event?: PointerEvent) => {
|
const cancelViewportDrag = (event?: PointerEvent) => {
|
||||||
const active = selection.value
|
const active = selection.value
|
||||||
if (!active || (event && event.pointerId !== active.pointerId)) return
|
if (!active || (event && event.pointerId !== active.pointerId)) return
|
||||||
active.overlay.releasePointerCapture?.(active.pointerId)
|
cleanupViewportDrag(active.pointerId, event)
|
||||||
selection.value = null
|
|
||||||
}
|
}
|
||||||
const applyBoxZoom = (active: ViewportSelectionState) => {
|
const applyBoxZoom = (active: ViewportSelectionState) => {
|
||||||
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
|
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
|
||||||
@@ -249,9 +234,14 @@ export function useWaveformViewport(context: ViewportContext) {
|
|||||||
)
|
)
|
||||||
if (right - left < MINIMUM_SELECTION_SIZE) return
|
if (right - left < MINIMUM_SELECTION_SIZE) return
|
||||||
const baseXDomain = active.independent ? resolveInitialTrackDomain(track) : initialXDomain.value
|
const baseXDomain = active.independent ? resolveInitialTrackDomain(track) : initialXDomain.value
|
||||||
|
const groups = active.independent
|
||||||
|
? [track.seriesList]
|
||||||
|
: trackLayouts.value.filter((item) => item.hasVisibleSeries).map((item) => item.seriesList)
|
||||||
const xDomain = constrainZoomDomain(
|
const xDomain = constrainZoomDomain(
|
||||||
[track.xScale.invert(left), track.xScale.invert(right)],
|
[track.xScale.invert(left), track.xScale.invert(right)],
|
||||||
baseXDomain,
|
baseXDomain,
|
||||||
|
groups,
|
||||||
|
props,
|
||||||
)
|
)
|
||||||
if (active.independent) {
|
if (active.independent) {
|
||||||
const next = [...independentTransforms.value]
|
const next = [...independentTransforms.value]
|
||||||
@@ -300,15 +290,38 @@ export function useWaveformViewport(context: ViewportContext) {
|
|||||||
}
|
}
|
||||||
const active = selection.value
|
const active = selection.value
|
||||||
if (!active || event.pointerId !== active.pointerId) return
|
if (!active || event.pointerId !== active.pointerId) return
|
||||||
updateViewportDrag(event)
|
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
|
||||||
active.overlay.releasePointerCapture?.(active.pointerId)
|
const overlay = activeOverlay.value
|
||||||
|
if (!track || !overlay || !overlay.parentNode) {
|
||||||
|
cleanupViewportDrag(active.pointerId, event)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const [rawX, rawY] = pointer(event, overlay)
|
||||||
|
const currentX = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(active.independent ? track.width : innerWidth.value, rawX),
|
||||||
|
)
|
||||||
|
const currentY = Math.max(
|
||||||
|
0,
|
||||||
|
Math.min(active.independent ? track.height : innerHeight.value, rawY),
|
||||||
|
)
|
||||||
|
const completed = transitionViewportInteraction(selection.value, {
|
||||||
|
type: 'finish',
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
position: { currentX, currentY },
|
||||||
|
}).completed
|
||||||
|
if (!completed) return
|
||||||
selection.value = null
|
selection.value = null
|
||||||
if (active.mode === 'pan') {
|
releasePointerCapture(completed.pointerId, event)
|
||||||
|
activeOverlay.value = undefined
|
||||||
|
event.preventDefault()
|
||||||
|
if (completed.kind === 'pan') {
|
||||||
|
applyPan(completed, track)
|
||||||
void nextTick(configureZoom)
|
void nextTick(configureZoom)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (Math.abs(active.currentX - active.startX) >= MINIMUM_SELECTION_SIZE) {
|
if (Math.abs(completed.currentX - completed.startX) >= MINIMUM_SELECTION_SIZE) {
|
||||||
applyBoxZoom(active)
|
applyBoxZoom(completed)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const resetViewport = (trackIndex?: number) => {
|
const resetViewport = (trackIndex?: number) => {
|
||||||
@@ -337,7 +350,6 @@ export function useWaveformViewport(context: ViewportContext) {
|
|||||||
resetViewport()
|
resetViewport()
|
||||||
emit('zoom-reset')
|
emit('zoom-reset')
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
selectionBox,
|
selectionBox,
|
||||||
beginViewportDrag,
|
beginViewportDrag,
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ import { WHEEL_ZOOM_DEBOUNCE_MS, ZOOM_CONSTRAINTS } from '../core/constants'
|
|||||||
import type { TrackLayout } from '../core/types'
|
import type { TrackLayout } from '../core/types'
|
||||||
import type { ResolvedWaveformChartProps, WaveformChartEmit } from '../core/waveformChartTypes'
|
import type { ResolvedWaveformChartProps, WaveformChartEmit } from '../core/waveformChartTypes'
|
||||||
import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
|
import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
|
||||||
|
import {
|
||||||
|
constrainZoomDomain,
|
||||||
|
resolveMinimumZoomSpan,
|
||||||
|
transformForDomain,
|
||||||
|
type ZoomSeriesGroup,
|
||||||
|
} from './zoomConstraints'
|
||||||
|
|
||||||
interface ZoomContext {
|
interface ZoomContext {
|
||||||
props: ResolvedWaveformChartProps
|
props: ResolvedWaveformChartProps
|
||||||
@@ -216,24 +222,38 @@ export function useWaveformZoom(context: ZoomContext) {
|
|||||||
.forEach((overlay) => select(overlay).on('.zoom', null))
|
.forEach((overlay) => select(overlay).on('.zoom', null))
|
||||||
zoomBehaviors.clear()
|
zoomBehaviors.clear()
|
||||||
}
|
}
|
||||||
const resolveMaximumZoomScale = (domain: [number, number]): number => {
|
const resolveMaximumZoomScale = (
|
||||||
const minZoomSpan = props.minZoomSpan
|
domain: [number, number],
|
||||||
if (!Number.isFinite(minZoomSpan) || (minZoomSpan ?? 0) <= 0) {
|
groups: readonly ZoomSeriesGroup[],
|
||||||
return ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE
|
): number => {
|
||||||
}
|
|
||||||
const domainSpan = Math.abs(domain[1] - domain[0])
|
const domainSpan = Math.abs(domain[1] - domain[0])
|
||||||
if (!Number.isFinite(domainSpan) || domainSpan <= 0) return ZOOM_CONSTRAINTS.MIN_SCALE
|
if (!Number.isFinite(domainSpan) || domainSpan <= 0) return ZOOM_CONSTRAINTS.MIN_SCALE
|
||||||
return Math.min(
|
const minimumSpan = resolveMinimumZoomSpan(domain, groups, props)
|
||||||
ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE,
|
return Math.max(
|
||||||
Math.max(ZOOM_CONSTRAINTS.MIN_SCALE, domainSpan / (minZoomSpan ?? domainSpan)),
|
ZOOM_CONSTRAINTS.MIN_SCALE,
|
||||||
|
minimumSpan > 0 ? domainSpan / minimumSpan : ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
const canZoomTrack = (track: TrackLayout): boolean =>
|
const constrainTransform = (
|
||||||
hasMinimumVisibleXValues(
|
transform: ZoomTransform,
|
||||||
|
domain: [number, number],
|
||||||
|
width: number,
|
||||||
|
groups: readonly ZoomSeriesGroup[],
|
||||||
|
): ZoomTransform => {
|
||||||
|
const requested = transform.rescaleX(scaleLinear(domain, [0, width])).domain() as [
|
||||||
|
number,
|
||||||
|
number,
|
||||||
|
]
|
||||||
|
return transformForDomain(constrainZoomDomain(requested, domain, groups, props), domain, width)
|
||||||
|
}
|
||||||
|
const canZoomTrack = (track: TrackLayout): boolean => {
|
||||||
|
const minimum = Number(props.minVisiblePoints)
|
||||||
|
return hasMinimumVisibleXValues(
|
||||||
track.seriesList,
|
track.seriesList,
|
||||||
track.xScale.domain() as [number, number],
|
track.xScale.domain() as [number, number],
|
||||||
Number(props.minVisiblePoints),
|
Number.isFinite(minimum) && minimum > 0 ? Math.ceil(minimum) + 1 : minimum,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
const canZoomSharedTracks = (): boolean => {
|
const canZoomSharedTracks = (): boolean => {
|
||||||
const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
|
const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
|
||||||
return tracks.length > 0 && tracks.every(canZoomTrack)
|
return tracks.length > 0 && tracks.every(canZoomTrack)
|
||||||
@@ -265,9 +285,15 @@ export function useWaveformZoom(context: ZoomContext) {
|
|||||||
)
|
)
|
||||||
if (!overlay) return
|
if (!overlay) return
|
||||||
const dataDomain = resolveInitialTrackDomain(track)
|
const dataDomain = resolveInitialTrackDomain(track)
|
||||||
|
const groups = [track.seriesList]
|
||||||
const behavior = zoom<SVGRectElement, unknown>()
|
const behavior = zoom<SVGRectElement, unknown>()
|
||||||
.filter((event) => canHandleWheelZoom(event, canZoomTrack(track)))
|
.filter((event) => {
|
||||||
.scaleExtent([1, resolveMaximumZoomScale(dataDomain)])
|
const currentTrack =
|
||||||
|
trackLayouts.value.find((item) => item.index === track.index) ?? track
|
||||||
|
return canHandleWheelZoom(event, canZoomTrack(currentTrack))
|
||||||
|
})
|
||||||
|
.scaleExtent([1, resolveMaximumZoomScale(dataDomain, groups)])
|
||||||
|
.constrain((transform) => constrainTransform(transform, dataDomain, track.width, groups))
|
||||||
.extent([
|
.extent([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[track.width, track.height],
|
[track.width, track.height],
|
||||||
@@ -293,9 +319,15 @@ export function useWaveformZoom(context: ZoomContext) {
|
|||||||
}
|
}
|
||||||
const overlay = sharedOverlayElement.value
|
const overlay = sharedOverlayElement.value
|
||||||
if (!overlay) return
|
if (!overlay) return
|
||||||
|
const groups = trackLayouts.value
|
||||||
|
.filter((track) => track.hasVisibleSeries)
|
||||||
|
.map((track) => track.seriesList)
|
||||||
const behavior = zoom<SVGRectElement, unknown>()
|
const behavior = zoom<SVGRectElement, unknown>()
|
||||||
.filter((event) => canHandleWheelZoom(event, canZoomSharedTracks()))
|
.filter((event) => canHandleWheelZoom(event, canZoomSharedTracks()))
|
||||||
.scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value)])
|
.scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value, groups)])
|
||||||
|
.constrain((transform) =>
|
||||||
|
constrainTransform(transform, initialXDomain.value, innerWidth.value, groups),
|
||||||
|
)
|
||||||
.extent([
|
.extent([
|
||||||
[0, 0],
|
[0, 0],
|
||||||
[innerWidth.value, innerHeight.value],
|
[innerWidth.value, innerHeight.value],
|
||||||
|
|||||||
101
src/components/interaction/viewportInteractionState.test.ts
Normal file
101
src/components/interaction/viewportInteractionState.test.ts
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import {
|
||||||
|
reduceViewportInteraction,
|
||||||
|
transitionViewportInteraction,
|
||||||
|
type ViewportInteractionEvent,
|
||||||
|
} from './viewportInteractionState'
|
||||||
|
|
||||||
|
const gesture = {
|
||||||
|
trackIndex: 2,
|
||||||
|
independent: true,
|
||||||
|
startX: 10,
|
||||||
|
startY: 20,
|
||||||
|
pointerId: 7,
|
||||||
|
kind: 'box' as const,
|
||||||
|
xDomain: [0, 100] as [number, number],
|
||||||
|
yDomains: { track: [-1, 1] as [number, number] },
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('viewport interaction reducer', () => {
|
||||||
|
it('accepts one begin and rejects a conflicting begin', () => {
|
||||||
|
const started = transitionViewportInteraction(null, { type: 'begin', gesture })
|
||||||
|
const conflicting = transitionViewportInteraction(started.state, {
|
||||||
|
type: 'begin',
|
||||||
|
gesture: { ...gesture, pointerId: 8 },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(started.accepted).toBe(true)
|
||||||
|
expect(started.state).toMatchObject({ kind: 'box', pointerId: 7 })
|
||||||
|
expect(conflicting.accepted).toBe(false)
|
||||||
|
expect(conflicting.state).toMatchObject({ kind: 'box', pointerId: 7 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects move and finish from a different pointer without changing state', () => {
|
||||||
|
const state = reduceViewportInteraction(null, { type: 'begin', gesture })
|
||||||
|
const move = transitionViewportInteraction(state, {
|
||||||
|
type: 'move',
|
||||||
|
pointerId: 8,
|
||||||
|
position: { currentX: 30, currentY: 40 },
|
||||||
|
})
|
||||||
|
const finish = transitionViewportInteraction(move.state, {
|
||||||
|
type: 'finish',
|
||||||
|
pointerId: 8,
|
||||||
|
position: { currentX: 30, currentY: 40 },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(move.accepted).toBe(false)
|
||||||
|
expect(finish.accepted).toBe(false)
|
||||||
|
expect(finish.state).toMatchObject({ currentX: 10, currentY: 20 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates a valid pointer and returns the completed snapshot on finish', () => {
|
||||||
|
const state = reduceViewportInteraction(null, {
|
||||||
|
type: 'begin',
|
||||||
|
gesture: { ...gesture, kind: 'pan' },
|
||||||
|
})
|
||||||
|
const moved = transitionViewportInteraction(state, {
|
||||||
|
type: 'move',
|
||||||
|
pointerId: 7,
|
||||||
|
position: { currentX: 30, currentY: 40 },
|
||||||
|
})
|
||||||
|
const finished = transitionViewportInteraction(moved.state, {
|
||||||
|
type: 'finish',
|
||||||
|
pointerId: 7,
|
||||||
|
position: { currentX: 50, currentY: 60 },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(moved.state).toMatchObject({ kind: 'pan', currentX: 30, currentY: 40 })
|
||||||
|
expect(finished.completed).toMatchObject({ kind: 'pan', currentX: 50, currentY: 60 })
|
||||||
|
expect(finished.state).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cancels only the owning pointer and supports cancel/reset events', () => {
|
||||||
|
const state = reduceViewportInteraction(null, { type: 'begin', gesture })
|
||||||
|
const rejectedCancel = transitionViewportInteraction(state, { type: 'cancel', pointerId: 8 })
|
||||||
|
const cancelled = transitionViewportInteraction(rejectedCancel.state, {
|
||||||
|
type: 'cancel',
|
||||||
|
pointerId: 7,
|
||||||
|
})
|
||||||
|
const restarted = reduceViewportInteraction(null, { type: 'begin', gesture })
|
||||||
|
const reset = transitionViewportInteraction(restarted, { type: 'reset' })
|
||||||
|
|
||||||
|
expect(rejectedCancel.accepted).toBe(false)
|
||||||
|
expect(rejectedCancel.state).not.toBeNull()
|
||||||
|
expect(cancelled.accepted).toBe(true)
|
||||||
|
expect(cancelled.state).toBeNull()
|
||||||
|
expect(reset.accepted).toBe(true)
|
||||||
|
expect(reset.state).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps events discriminated and does not mutate the begin input', () => {
|
||||||
|
const event: ViewportInteractionEvent = { type: 'begin', gesture }
|
||||||
|
const state = reduceViewportInteraction(null, event)
|
||||||
|
|
||||||
|
expect(state).not.toBeNull()
|
||||||
|
expect(state?.xDomain).toEqual([0, 100])
|
||||||
|
expect(state?.yDomains.track).toEqual([-1, 1])
|
||||||
|
expect(event.gesture.xDomain).toEqual([0, 100])
|
||||||
|
expect(event.gesture.yDomains.track).toEqual([-1, 1])
|
||||||
|
})
|
||||||
|
})
|
||||||
89
src/components/interaction/viewportInteractionState.ts
Normal file
89
src/components/interaction/viewportInteractionState.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import type { ViewportSelectionState } from '../core/waveformChartTypes'
|
||||||
|
|
||||||
|
export interface ViewportGestureStart {
|
||||||
|
trackIndex: number
|
||||||
|
independent: boolean
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
pointerId: number
|
||||||
|
kind: 'box' | 'pan'
|
||||||
|
xDomain: [number, number]
|
||||||
|
yDomains: Record<string, [number, number]>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ViewportGesturePosition {
|
||||||
|
currentX: number
|
||||||
|
currentY: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ViewportInteractionEvent =
|
||||||
|
| { type: 'begin'; gesture: ViewportGestureStart }
|
||||||
|
| { type: 'move'; pointerId: number; position: ViewportGesturePosition }
|
||||||
|
| { type: 'finish'; pointerId: number; position: ViewportGesturePosition }
|
||||||
|
| { type: 'cancel'; pointerId?: number }
|
||||||
|
| { type: 'reset' }
|
||||||
|
|
||||||
|
export interface ViewportInteractionTransition {
|
||||||
|
state: ViewportSelectionState | null
|
||||||
|
accepted: boolean
|
||||||
|
completed: ViewportSelectionState | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSelectionState(input: ViewportGestureStart): ViewportSelectionState {
|
||||||
|
return {
|
||||||
|
...input,
|
||||||
|
currentX: input.startX,
|
||||||
|
currentY: input.startY,
|
||||||
|
xDomain: [...input.xDomain],
|
||||||
|
yDomains: Object.fromEntries(
|
||||||
|
Object.entries(input.yDomains).map(([key, domain]) => [key, [...domain] as [number, number]]),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function withPosition(
|
||||||
|
state: ViewportSelectionState,
|
||||||
|
position: ViewportGesturePosition,
|
||||||
|
): ViewportSelectionState {
|
||||||
|
return { ...state, ...position }
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectedTransition(state: ViewportSelectionState | null): ViewportInteractionTransition {
|
||||||
|
return { state, accepted: false, completed: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transitionViewportInteraction(
|
||||||
|
state: ViewportSelectionState | null,
|
||||||
|
event: ViewportInteractionEvent,
|
||||||
|
): ViewportInteractionTransition {
|
||||||
|
switch (event.type) {
|
||||||
|
case 'begin':
|
||||||
|
return state
|
||||||
|
? rejectedTransition(state)
|
||||||
|
: { state: createSelectionState(event.gesture), accepted: true, completed: null }
|
||||||
|
case 'move':
|
||||||
|
if (!state || state.pointerId !== event.pointerId) return rejectedTransition(state)
|
||||||
|
return { state: withPosition(state, event.position), accepted: true, completed: null }
|
||||||
|
case 'finish':
|
||||||
|
if (!state || state.pointerId !== event.pointerId) return rejectedTransition(state)
|
||||||
|
return {
|
||||||
|
state: null,
|
||||||
|
accepted: true,
|
||||||
|
completed: withPosition(state, event.position),
|
||||||
|
}
|
||||||
|
case 'cancel':
|
||||||
|
if (!state || (event.pointerId !== undefined && state.pointerId !== event.pointerId)) {
|
||||||
|
return rejectedTransition(state)
|
||||||
|
}
|
||||||
|
return { state: null, accepted: true, completed: null }
|
||||||
|
case 'reset':
|
||||||
|
return { state: null, accepted: true, completed: null }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reduceViewportInteraction(
|
||||||
|
state: ViewportSelectionState | null,
|
||||||
|
event: ViewportInteractionEvent,
|
||||||
|
): ViewportSelectionState | null {
|
||||||
|
return transitionViewportInteraction(state, event).state
|
||||||
|
}
|
||||||
167
src/components/interaction/zoomConstraints.ts
Normal file
167
src/components/interaction/zoomConstraints.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
|
||||||
|
|
||||||
|
import type { WaveformPoint } from '../../types'
|
||||||
|
import { ZOOM_CONSTRAINTS } from '../core/constants'
|
||||||
|
|
||||||
|
interface PointSeriesSource {
|
||||||
|
points: WaveformPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ZoomSeriesGroup = readonly PointSeriesSource[]
|
||||||
|
|
||||||
|
interface ZoomConstraintOptions {
|
||||||
|
minZoomSpan?: number
|
||||||
|
minVisiblePoints?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const xValueCache = new WeakMap<object, Map<string, number[]>>()
|
||||||
|
|
||||||
|
function normalizedBoundary(boundary: [number, number]): [number, number] {
|
||||||
|
return boundary[0] <= boundary[1] ? [...boundary] : [boundary[1], boundary[0]]
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveRequiredPointCount(minimum: number | undefined): number {
|
||||||
|
return Number.isFinite(minimum) && (minimum ?? 0) > 0 ? Math.ceil(minimum as number) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueXValues(group: ZoomSeriesGroup, boundary: [number, number]): number[] {
|
||||||
|
const boundaryKey = `${boundary[0]}\u0000${boundary[1]}`
|
||||||
|
const cached = xValueCache.get(group)?.get(boundaryKey)
|
||||||
|
if (cached) return cached
|
||||||
|
const values = new Set<number>()
|
||||||
|
for (const series of group) {
|
||||||
|
for (const point of series.points) {
|
||||||
|
if (point.x >= boundary[0] && point.x <= boundary[1]) values.add(point.x)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const sorted = Array.from(values).sort((left, right) => left - right)
|
||||||
|
const groupCache = xValueCache.get(group) ?? new Map<string, number[]>()
|
||||||
|
groupCache.set(boundaryKey, sorted)
|
||||||
|
xValueCache.set(group, groupCache)
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
function minimumPointSpan(
|
||||||
|
groups: readonly ZoomSeriesGroup[],
|
||||||
|
boundary: [number, number],
|
||||||
|
required: number,
|
||||||
|
): number {
|
||||||
|
if (required <= 1) return 0
|
||||||
|
let minimumSpan = 0
|
||||||
|
for (const group of groups) {
|
||||||
|
const values = uniqueXValues(group, boundary)
|
||||||
|
if (values.length < required) return boundary[1] - boundary[0]
|
||||||
|
let groupSpan = Number.POSITIVE_INFINITY
|
||||||
|
for (let index = 0; index + required <= values.length; index += 1) {
|
||||||
|
groupSpan = Math.min(groupSpan, values[index + required - 1] - values[index])
|
||||||
|
}
|
||||||
|
const endpointTolerance =
|
||||||
|
Number.EPSILON * Math.max(1, Math.abs(boundary[0]), Math.abs(boundary[1])) * 16
|
||||||
|
minimumSpan = Math.max(minimumSpan, groupSpan + endpointTolerance)
|
||||||
|
}
|
||||||
|
return minimumSpan
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMinimumZoomSpan(
|
||||||
|
boundary: [number, number],
|
||||||
|
groups: readonly ZoomSeriesGroup[],
|
||||||
|
options: ZoomConstraintOptions,
|
||||||
|
): number {
|
||||||
|
const normalized = normalizedBoundary(boundary)
|
||||||
|
const boundarySpan = normalized[1] - normalized[0]
|
||||||
|
if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return 0
|
||||||
|
const configuredSpan =
|
||||||
|
Number.isFinite(options.minZoomSpan) && (options.minZoomSpan ?? 0) > 0
|
||||||
|
? Math.min(boundarySpan, options.minZoomSpan as number)
|
||||||
|
: 0
|
||||||
|
const required = resolveRequiredPointCount(options.minVisiblePoints)
|
||||||
|
const pointSpan = minimumPointSpan(groups, normalized, required)
|
||||||
|
if (configuredSpan > 0 || required > 0) {
|
||||||
|
return Math.max(configuredSpan, pointSpan)
|
||||||
|
}
|
||||||
|
return boundarySpan / ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandToMinimumPoints(
|
||||||
|
domain: [number, number],
|
||||||
|
boundary: [number, number],
|
||||||
|
groups: readonly ZoomSeriesGroup[],
|
||||||
|
required: number,
|
||||||
|
): [number, number] {
|
||||||
|
if (required <= 0) return domain
|
||||||
|
const endpointTolerance =
|
||||||
|
Number.EPSILON * Math.max(1, Math.abs(boundary[0]), Math.abs(boundary[1])) * 16
|
||||||
|
let expanded = domain
|
||||||
|
for (const group of groups) {
|
||||||
|
const values = uniqueXValues(group, boundary)
|
||||||
|
if (values.length < required) return [...boundary]
|
||||||
|
const visibleCount = values.filter(
|
||||||
|
(value) => value >= expanded[0] && value <= expanded[1],
|
||||||
|
).length
|
||||||
|
if (visibleCount >= required) continue
|
||||||
|
let best: [number, number] | undefined
|
||||||
|
let bestSpan = Number.POSITIVE_INFINITY
|
||||||
|
let bestCenterDistance = Number.POSITIVE_INFINITY
|
||||||
|
const requestedCenter = (expanded[0] + expanded[1]) / 2
|
||||||
|
for (let index = 0; index + required <= values.length; index += 1) {
|
||||||
|
const candidate: [number, number] = [
|
||||||
|
Math.max(boundary[0], Math.min(expanded[0], values[index] - endpointTolerance)),
|
||||||
|
Math.min(
|
||||||
|
boundary[1],
|
||||||
|
Math.max(expanded[1], values[index + required - 1] + endpointTolerance),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
const span = candidate[1] - candidate[0]
|
||||||
|
const centerDistance = Math.abs((candidate[0] + candidate[1]) / 2 - requestedCenter)
|
||||||
|
if (span < bestSpan || (span === bestSpan && centerDistance < bestCenterDistance)) {
|
||||||
|
best = candidate
|
||||||
|
bestSpan = span
|
||||||
|
bestCenterDistance = centerDistance
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expanded = best ?? [...boundary]
|
||||||
|
}
|
||||||
|
return expanded
|
||||||
|
}
|
||||||
|
|
||||||
|
export function constrainZoomDomain(
|
||||||
|
domain: [number, number],
|
||||||
|
boundary: [number, number],
|
||||||
|
groups: readonly ZoomSeriesGroup[],
|
||||||
|
options: ZoomConstraintOptions,
|
||||||
|
): [number, number] {
|
||||||
|
const normalized = normalizedBoundary(boundary)
|
||||||
|
const boundarySpan = normalized[1] - normalized[0]
|
||||||
|
if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return normalized
|
||||||
|
if (Math.abs(domain[1] - domain[0]) >= boundarySpan * (1 - 1e-12)) return normalized
|
||||||
|
const requested: [number, number] = [
|
||||||
|
Math.max(normalized[0], Math.min(domain[0], domain[1])),
|
||||||
|
Math.min(normalized[1], Math.max(domain[0], domain[1])),
|
||||||
|
]
|
||||||
|
const expanded = expandToMinimumPoints(
|
||||||
|
requested,
|
||||||
|
normalized,
|
||||||
|
groups,
|
||||||
|
resolveRequiredPointCount(options.minVisiblePoints),
|
||||||
|
)
|
||||||
|
const minimumSpan = resolveMinimumZoomSpan(normalized, groups, options)
|
||||||
|
const span = Math.max(minimumSpan, Math.min(boundarySpan, expanded[1] - expanded[0]))
|
||||||
|
const center = (expanded[0] + expanded[1]) / 2
|
||||||
|
const start = Math.max(normalized[0], Math.min(center - span / 2, normalized[1] - span))
|
||||||
|
return [start, start + span]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function transformForDomain(
|
||||||
|
domain: [number, number],
|
||||||
|
baseDomain: [number, number],
|
||||||
|
width: number,
|
||||||
|
): ZoomTransform {
|
||||||
|
const baseSpan = baseDomain[1] - baseDomain[0]
|
||||||
|
const span = domain[1] - domain[0]
|
||||||
|
if (!Number.isFinite(baseSpan) || !Number.isFinite(span) || baseSpan <= 0 || span <= 0) {
|
||||||
|
return zoomIdentity
|
||||||
|
}
|
||||||
|
const scale = baseSpan / span
|
||||||
|
const baseScale = scaleLinear(baseDomain, [0, width])
|
||||||
|
return zoomIdentity.translate(-scale * baseScale(domain[0]), 0).scale(scale)
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ export type {
|
|||||||
WaveformAnnotationStyle,
|
WaveformAnnotationStyle,
|
||||||
WaveformAnnotation,
|
WaveformAnnotation,
|
||||||
WaveformRenderingOptions,
|
WaveformRenderingOptions,
|
||||||
|
WaveformPlotMargin,
|
||||||
WaveformTitleTextStyle,
|
WaveformTitleTextStyle,
|
||||||
WaveformTitleOptions,
|
WaveformTitleOptions,
|
||||||
WaveformFrameStyle,
|
WaveformFrameStyle,
|
||||||
|
|||||||
@@ -105,6 +105,65 @@ describe('WaveformChart', () => {
|
|||||||
expect(wrapper.get('.waveform-chart__svg').attributes('height')).toBe('300')
|
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 () => {
|
it('does not render or reserve space for missing, hidden, or blank titles', async () => {
|
||||||
for (const title of [undefined, { visible: false, text: '隐藏标题' }, { text: ' ' }]) {
|
for (const title of [undefined, { visible: false, text: '隐藏标题' }, { text: ' ' }]) {
|
||||||
const wrapper = await mountSizedChart(
|
const wrapper = await mountSizedChart(
|
||||||
|
|||||||
132
src/components/waveformChartCases/viewportLifecycle.test.ts
Normal file
132
src/components/waveformChartCases/viewportLifecycle.test.ts
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import { flushPromises } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it, vi } from 'vitest'
|
||||||
|
|
||||||
|
import { gridSeries, mountSizedChart } from '../../test/waveformChart'
|
||||||
|
import WaveformChartView from '../WaveformChartView.vue'
|
||||||
|
|
||||||
|
describe('WaveformChart viewport lifecycle', () => {
|
||||||
|
it('cleans an active shared gesture when its track is removed', async () => {
|
||||||
|
const wrapper = await mountSizedChart(gridSeries(2), {
|
||||||
|
displayMode: 'separated',
|
||||||
|
grid: { rowCount: 2, columnCount: 1 },
|
||||||
|
})
|
||||||
|
const overlay = wrapper.get('.waveform-chart__overlay--shared')
|
||||||
|
const width = Number(overlay.attributes('width'))
|
||||||
|
const height = Number(overlay.attributes('height'))
|
||||||
|
const releasePointerCapture = vi.fn()
|
||||||
|
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||||
|
value: () => ({ left: 0, top: 0, width, height }),
|
||||||
|
})
|
||||||
|
Object.defineProperty(overlay.element, 'setPointerCapture', { value: vi.fn() })
|
||||||
|
Object.defineProperty(overlay.element, 'releasePointerCapture', {
|
||||||
|
value: releasePointerCapture,
|
||||||
|
})
|
||||||
|
|
||||||
|
const dispatchPointer = (type: string, pointerId: number, clientY: number) => {
|
||||||
|
const event = new MouseEvent(type, {
|
||||||
|
button: 0,
|
||||||
|
clientX: width / 2,
|
||||||
|
clientY,
|
||||||
|
bubbles: true,
|
||||||
|
})
|
||||||
|
Object.defineProperty(event, 'pointerId', { value: pointerId })
|
||||||
|
overlay.element.dispatchEvent(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchPointer('pointerdown', 41, height * 0.75)
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(true)
|
||||||
|
|
||||||
|
await wrapper.setProps({ data: gridSeries(1) })
|
||||||
|
await flushPromises()
|
||||||
|
dispatchPointer('pointerup', 41, height * 0.75)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(releasePointerCapture).toHaveBeenCalledWith(41)
|
||||||
|
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(false)
|
||||||
|
|
||||||
|
dispatchPointer('pointerdown', 42, height / 2)
|
||||||
|
dispatchPointer('pointermove', 42, height / 2 + 20)
|
||||||
|
await flushPromises()
|
||||||
|
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('cleans an active gesture when its overlay leaves the DOM', async () => {
|
||||||
|
const wrapper = await mountSizedChart(
|
||||||
|
{
|
||||||
|
kind: 'points',
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 0 },
|
||||||
|
{ x: 1, y: 1 },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{ displayMode: 'separated' },
|
||||||
|
)
|
||||||
|
const overlay = wrapper.get('.waveform-chart__overlay--shared')
|
||||||
|
const width = Number(overlay.attributes('width'))
|
||||||
|
const height = Number(overlay.attributes('height'))
|
||||||
|
const releasePointerCapture = vi.fn(() => {
|
||||||
|
throw new DOMException('Pointer capture is no longer available', 'NotFoundError')
|
||||||
|
})
|
||||||
|
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||||
|
value: () => ({ left: 0, top: 0, width, height }),
|
||||||
|
})
|
||||||
|
Object.defineProperty(overlay.element, 'setPointerCapture', { value: vi.fn() })
|
||||||
|
Object.defineProperty(overlay.element, 'releasePointerCapture', {
|
||||||
|
value: releasePointerCapture,
|
||||||
|
})
|
||||||
|
|
||||||
|
const down = new MouseEvent('pointerdown', {
|
||||||
|
button: 0,
|
||||||
|
clientX: width / 2,
|
||||||
|
clientY: height / 2,
|
||||||
|
bubbles: true,
|
||||||
|
})
|
||||||
|
Object.defineProperty(down, 'pointerId', { value: 43 })
|
||||||
|
overlay.element.dispatchEvent(down)
|
||||||
|
await wrapper.setProps({ hiddenSeriesIds: ['series-0'] })
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.waveform-chart__overlay--shared').exists()).toBe(false)
|
||||||
|
const view = wrapper.findComponent(WaveformChartView)
|
||||||
|
const finishViewportDrag = (
|
||||||
|
view.vm as unknown as { finishViewportDrag: (event: PointerEvent) => void }
|
||||||
|
).finishViewportDrag
|
||||||
|
const up = new MouseEvent('pointerup', {
|
||||||
|
clientX: width / 2,
|
||||||
|
clientY: height / 2,
|
||||||
|
bubbles: true,
|
||||||
|
})
|
||||||
|
Object.defineProperty(up, 'pointerId', { value: 43 })
|
||||||
|
finishViewportDrag(up as unknown as PointerEvent)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(releasePointerCapture).toHaveBeenCalledWith(43)
|
||||||
|
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(false)
|
||||||
|
|
||||||
|
await wrapper.setProps({ hiddenSeriesIds: [] })
|
||||||
|
await flushPromises()
|
||||||
|
const nextOverlay = wrapper.get('.waveform-chart__overlay--shared')
|
||||||
|
Object.defineProperty(nextOverlay.element, 'getBoundingClientRect', {
|
||||||
|
value: () => ({ left: 0, top: 0, width, height }),
|
||||||
|
})
|
||||||
|
const nextDown = new MouseEvent('pointerdown', {
|
||||||
|
button: 0,
|
||||||
|
clientX: width / 2,
|
||||||
|
clientY: height / 2,
|
||||||
|
bubbles: true,
|
||||||
|
})
|
||||||
|
Object.defineProperty(nextDown, 'pointerId', { value: 44 })
|
||||||
|
nextOverlay.element.dispatchEvent(nextDown)
|
||||||
|
const nextMove = new MouseEvent('pointermove', {
|
||||||
|
clientX: width * 0.75,
|
||||||
|
clientY: height / 2,
|
||||||
|
bubbles: true,
|
||||||
|
})
|
||||||
|
Object.defineProperty(nextMove, 'pointerId', { value: 44 })
|
||||||
|
nextOverlay.element.dispatchEvent(nextMove)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(true)
|
||||||
|
})
|
||||||
|
})
|
||||||
248
src/components/waveformChartCases/zoomPointConstraints.test.ts
Normal file
248
src/components/waveformChartCases/zoomPointConstraints.test.ts
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import { flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import { flushAnimationFrames } from '../../test/setup'
|
||||||
|
import { mountSizedChart } from '../../test/waveformChart'
|
||||||
|
|
||||||
|
const regularPoints = Array.from({ length: 1_200 }, (_, index) => ({ x: index, y: index % 11 }))
|
||||||
|
|
||||||
|
function visibleXCount(domain: [number, number], values = regularPoints): number {
|
||||||
|
return new Set(
|
||||||
|
values.filter((point) => point.x >= domain[0] && point.x <= domain[1]).map((point) => point.x),
|
||||||
|
).size
|
||||||
|
}
|
||||||
|
|
||||||
|
function prepareOverlay(wrapper: VueWrapper, selector: string) {
|
||||||
|
const overlay = wrapper.get(selector)
|
||||||
|
const width = Number(overlay.attributes('width'))
|
||||||
|
const height = Number(overlay.attributes('height'))
|
||||||
|
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
|
||||||
|
value: () => ({ left: 0, top: 0, width, height }),
|
||||||
|
})
|
||||||
|
return { overlay, width, height }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dispatchWheel(
|
||||||
|
overlay: ReturnType<VueWrapper['get']>,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
deltaY: number,
|
||||||
|
) {
|
||||||
|
overlay.element.dispatchEvent(
|
||||||
|
new WheelEvent('wheel', {
|
||||||
|
deltaY,
|
||||||
|
clientX: width / 2,
|
||||||
|
clientY: height / 2,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
flushAnimationFrames()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
function dispatchBox(
|
||||||
|
overlay: ReturnType<VueWrapper['get']>,
|
||||||
|
startX: number,
|
||||||
|
endX: number,
|
||||||
|
height: number,
|
||||||
|
pointerId: number,
|
||||||
|
) {
|
||||||
|
for (const [type, clientX] of [
|
||||||
|
['pointerdown', startX],
|
||||||
|
['pointermove', endX],
|
||||||
|
['pointerup', endX],
|
||||||
|
] as const) {
|
||||||
|
const event = new MouseEvent(type, {
|
||||||
|
button: 0,
|
||||||
|
clientX,
|
||||||
|
clientY: height / 2,
|
||||||
|
bubbles: true,
|
||||||
|
})
|
||||||
|
Object.defineProperty(event, 'pointerId', { value: pointerId })
|
||||||
|
overlay.element.dispatchEvent(event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('WaveformChart point-aware zoom constraints', () => {
|
||||||
|
it('wheel-zooms 1,200 samples to two distinct X values, stops, and still zooms out', async () => {
|
||||||
|
const wrapper = await mountSizedChart(
|
||||||
|
{ kind: 'points', points: regularPoints },
|
||||||
|
{ minVisiblePoints: 2 },
|
||||||
|
)
|
||||||
|
const { overlay, width, height } = prepareOverlay(
|
||||||
|
wrapper,
|
||||||
|
'.waveform-chart__overlay--independent',
|
||||||
|
)
|
||||||
|
|
||||||
|
await dispatchWheel(overlay, width, height, -4_000)
|
||||||
|
await dispatchWheel(overlay, width, height, -4_000)
|
||||||
|
const twoPointDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||||
|
expect(visibleXCount(twoPointDomain)).toBe(2)
|
||||||
|
|
||||||
|
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
|
||||||
|
await dispatchWheel(overlay, width, height, -1_000)
|
||||||
|
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount)
|
||||||
|
|
||||||
|
await dispatchWheel(overlay, width, height, 4_000)
|
||||||
|
const partiallyZoomedOut = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||||
|
expect(partiallyZoomedOut[1] - partiallyZoomedOut[0]).toBeGreaterThan(
|
||||||
|
twoPointDomain[1] - twoPointDomain[0],
|
||||||
|
)
|
||||||
|
await dispatchWheel(overlay, width, height, 4_000)
|
||||||
|
const fullyZoomedOut = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||||
|
expect(fullyZoomedOut).toEqual([0, 1_199])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps two fractional samples when the wheel focus falls between sample positions', async () => {
|
||||||
|
const points = Array.from({ length: 1_000 }, (_, index) => ({
|
||||||
|
x: -5 + (index * 10) / 999,
|
||||||
|
y: index,
|
||||||
|
}))
|
||||||
|
const wrapper = await mountSizedChart(
|
||||||
|
{ kind: 'points', points },
|
||||||
|
{ minVisiblePoints: 2, initialXDomain: [-5, 5] },
|
||||||
|
)
|
||||||
|
const { overlay, width, height } = prepareOverlay(
|
||||||
|
wrapper,
|
||||||
|
'.waveform-chart__overlay--independent',
|
||||||
|
)
|
||||||
|
|
||||||
|
for (let index = 0; index < 3; index += 1) {
|
||||||
|
overlay.element.dispatchEvent(
|
||||||
|
new WheelEvent('wheel', {
|
||||||
|
deltaY: -4_000,
|
||||||
|
clientX: width * 0.5032,
|
||||||
|
clientY: height / 2,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
flushAnimationFrames()
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||||
|
expect(visibleXCount(domain, points)).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses repeated box zooms to reach exactly two distinct X values', async () => {
|
||||||
|
const wrapper = await mountSizedChart(
|
||||||
|
{ kind: 'points', points: regularPoints },
|
||||||
|
{ minVisiblePoints: 2 },
|
||||||
|
)
|
||||||
|
const { overlay, width, height } = prepareOverlay(
|
||||||
|
wrapper,
|
||||||
|
'.waveform-chart__overlay--independent',
|
||||||
|
)
|
||||||
|
|
||||||
|
for (let index = 0; index < 3; index += 1) {
|
||||||
|
dispatchBox(overlay, width / 2 - 4, width / 2 + 4, height, index + 1)
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||||
|
expect(visibleXCount(domain)).toBe(2)
|
||||||
|
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
|
||||||
|
dispatchBox(overlay, width / 2 - 4, width / 2 + 4, height, 10)
|
||||||
|
await flushPromises()
|
||||||
|
const constrainedDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||||
|
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount)
|
||||||
|
expect(visibleXCount(constrainedDomain)).toBe(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retains the default 40x maximum when no explicit limit is configured', async () => {
|
||||||
|
const wrapper = await mountSizedChart({ kind: 'points', points: regularPoints })
|
||||||
|
const { overlay, width, height } = prepareOverlay(
|
||||||
|
wrapper,
|
||||||
|
'.waveform-chart__overlay--independent',
|
||||||
|
)
|
||||||
|
|
||||||
|
await dispatchWheel(overlay, width, height, -4_000)
|
||||||
|
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||||
|
expect(domain[1] - domain[0]).toBeCloseTo(1_199 / 40)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retains two endpoint samples while replacing an independent data window', async () => {
|
||||||
|
const sourcePoints = Array.from({ length: 1_000 }, (_, index) => ({
|
||||||
|
x: -5 + (index * 10) / 999,
|
||||||
|
y: index,
|
||||||
|
}))
|
||||||
|
const wrapper = await mountSizedChart(
|
||||||
|
{ kind: 'points', points: sourcePoints },
|
||||||
|
{ minVisiblePoints: 2, initialXDomain: [-5, 5] },
|
||||||
|
)
|
||||||
|
const { overlay, width, height } = prepareOverlay(
|
||||||
|
wrapper,
|
||||||
|
'.waveform-chart__overlay--independent',
|
||||||
|
)
|
||||||
|
for (let index = 0; index < 10; index += 1) {
|
||||||
|
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
|
||||||
|
overlay.element.dispatchEvent(
|
||||||
|
new WheelEvent('wheel', {
|
||||||
|
deltaY: -1_000,
|
||||||
|
clientX: width * 0.5032,
|
||||||
|
clientY: height / 2,
|
||||||
|
bubbles: true,
|
||||||
|
cancelable: true,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
flushAnimationFrames()
|
||||||
|
await flushPromises()
|
||||||
|
if ((wrapper.emitted('zoom-change')?.length ?? 0) === eventCount) break
|
||||||
|
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||||
|
const windowPoints = sourcePoints.filter(
|
||||||
|
(point) => point.x >= domain[0] && point.x <= domain[1],
|
||||||
|
)
|
||||||
|
await wrapper.setProps({ data: { kind: 'points', points: windowPoints } })
|
||||||
|
await flushPromises()
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||||
|
const endpointDomain = wrapper
|
||||||
|
.findAll('.waveform-chart__axis-endpoint')
|
||||||
|
.slice(0, 2)
|
||||||
|
.map((endpoint) => Number(endpoint.text()) / 1_000) as [number, number]
|
||||||
|
expect(visibleXCount(finalDomain, sourcePoints)).toBe(2)
|
||||||
|
expect(visibleXCount(endpointDomain, sourcePoints)).toBe(2)
|
||||||
|
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
|
||||||
|
await dispatchWheel(overlay, width, height, -1_000)
|
||||||
|
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('recomputes irregular point constraints for shared tracks after data replacement', async () => {
|
||||||
|
const createData = (offset: number) => ({
|
||||||
|
kind: 'series' as const,
|
||||||
|
series: [
|
||||||
|
{
|
||||||
|
id: 'first',
|
||||||
|
name: 'First',
|
||||||
|
data: {
|
||||||
|
kind: 'points' as const,
|
||||||
|
points: [0, 1, 4, 20].map((x) => ({ x: x + offset, y: x })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'second',
|
||||||
|
name: 'Second',
|
||||||
|
data: {
|
||||||
|
kind: 'points' as const,
|
||||||
|
points: [0, 3, 9, 20].map((x) => ({ x: x + offset, y: x })),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
const wrapper = await mountSizedChart(createData(0), {
|
||||||
|
displayMode: 'separated',
|
||||||
|
minVisiblePoints: 2,
|
||||||
|
})
|
||||||
|
const { overlay, width, height } = prepareOverlay(wrapper, '.waveform-chart__overlay')
|
||||||
|
|
||||||
|
await dispatchWheel(overlay, width, height, -4_000)
|
||||||
|
await wrapper.setProps({ data: createData(100) })
|
||||||
|
await flushPromises()
|
||||||
|
await dispatchWheel(overlay, width, height, 4_000)
|
||||||
|
const domain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
|
||||||
|
expect(domain).toEqual([100, 120])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -3,6 +3,10 @@ import { bisector } from 'd3'
|
|||||||
import type { WaveformPoint } from '@/types'
|
import type { WaveformPoint } from '@/types'
|
||||||
import { resolveWaveformPointErrors } from './data'
|
import { resolveWaveformPointErrors } from './data'
|
||||||
import type { ResolvedWaveformRenderingOptions } from './renderingOptions'
|
import type { ResolvedWaveformRenderingOptions } from './renderingOptions'
|
||||||
|
import {
|
||||||
|
resolveRenderablePointSelectionStrategy,
|
||||||
|
type VisiblePointRange,
|
||||||
|
} from './renderingStrategies'
|
||||||
|
|
||||||
export {
|
export {
|
||||||
DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||||
@@ -13,11 +17,6 @@ export {
|
|||||||
const pointBisector = bisector((point: WaveformPoint) => point.x)
|
const pointBisector = bisector((point: WaveformPoint) => point.x)
|
||||||
const acceptAllPoints = () => true
|
const acceptAllPoints = () => true
|
||||||
|
|
||||||
interface VisiblePointRange {
|
|
||||||
start: number
|
|
||||||
end: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface PointSeriesSource {
|
interface PointSeriesSource {
|
||||||
points: WaveformPoint[]
|
points: WaveformPoint[]
|
||||||
}
|
}
|
||||||
@@ -65,10 +64,6 @@ export function hasMinimumVisibleXValues(
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefined) {
|
|
||||||
if (point && target[target.length - 1] !== point) target.push(point)
|
|
||||||
}
|
|
||||||
|
|
||||||
function selectRenderablePointsInRange(
|
function selectRenderablePointsInRange(
|
||||||
points: WaveformPoint[],
|
points: WaveformPoint[],
|
||||||
range: VisiblePointRange,
|
range: VisiblePointRange,
|
||||||
@@ -76,82 +71,17 @@ function selectRenderablePointsInRange(
|
|||||||
width: number,
|
width: number,
|
||||||
options: ResolvedWaveformRenderingOptions,
|
options: ResolvedWaveformRenderingOptions,
|
||||||
): WaveformPoint[] {
|
): WaveformPoint[] {
|
||||||
const domainStart = Math.min(domain[0], domain[1])
|
|
||||||
const domainEnd = Math.max(domain[0], domain[1])
|
|
||||||
const start = Math.max(0, range.start - 1)
|
const start = Math.max(0, range.start - 1)
|
||||||
const end = Math.min(points.length, range.end + 1)
|
const end = Math.min(points.length, range.end + 1)
|
||||||
const visibleCount = end - start
|
const visibleCount = end - start
|
||||||
if (visibleCount <= 0) return []
|
if (visibleCount <= 0) return []
|
||||||
if (!options.downsample || visibleCount <= options.downsampleThreshold) {
|
return resolveRenderablePointSelectionStrategy({ visibleCount, width, options })({
|
||||||
return points.slice(start, end)
|
points,
|
||||||
}
|
range,
|
||||||
|
domain,
|
||||||
const maximumPointCount = Math.max(4, Math.floor(width * options.maxPointsPerPixel))
|
width,
|
||||||
const bucketCount = Math.max(1, Math.floor(maximumPointCount / 4))
|
options,
|
||||||
if (visibleCount <= maximumPointCount) return points.slice(start, end)
|
})
|
||||||
|
|
||||||
const result: WaveformPoint[] = []
|
|
||||||
const span = domainEnd - domainStart || 1
|
|
||||||
const bucketIndexes = Array.from({ length: 4 }, () => -1)
|
|
||||||
let activeBucket = -1
|
|
||||||
let firstIndex = -1
|
|
||||||
let lastIndex = -1
|
|
||||||
let minimumIndex = -1
|
|
||||||
let maximumIndex = -1
|
|
||||||
|
|
||||||
const addBucketIndex = (index: number, count: number) => {
|
|
||||||
if (index < 0) return count
|
|
||||||
for (let position = 0; position < count; position += 1) {
|
|
||||||
if (bucketIndexes[position] === index) return count
|
|
||||||
}
|
|
||||||
bucketIndexes[count] = index
|
|
||||||
return count + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
const flushBucket = () => {
|
|
||||||
if (firstIndex < 0) return
|
|
||||||
let count = 0
|
|
||||||
count = addBucketIndex(firstIndex, count)
|
|
||||||
count = addBucketIndex(minimumIndex, count)
|
|
||||||
count = addBucketIndex(maximumIndex, count)
|
|
||||||
count = addBucketIndex(lastIndex, count)
|
|
||||||
for (let index = 1; index < count; index += 1) {
|
|
||||||
const value = bucketIndexes[index]
|
|
||||||
let position = index - 1
|
|
||||||
while (position >= 0 && bucketIndexes[position] > value) {
|
|
||||||
bucketIndexes[position + 1] = bucketIndexes[position]
|
|
||||||
position -= 1
|
|
||||||
}
|
|
||||||
bucketIndexes[position + 1] = value
|
|
||||||
}
|
|
||||||
for (let index = 0; index < count; index += 1) {
|
|
||||||
pushUniquePoint(result, points[bucketIndexes[index]])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pushUniquePoint(result, points[start])
|
|
||||||
for (let index = range.start; index < range.end; index += 1) {
|
|
||||||
const point = points[index]
|
|
||||||
const bucket = Math.min(
|
|
||||||
bucketCount - 1,
|
|
||||||
Math.max(0, Math.floor(((point.x - domainStart) / span) * bucketCount)),
|
|
||||||
)
|
|
||||||
if (bucket !== activeBucket) {
|
|
||||||
flushBucket()
|
|
||||||
activeBucket = bucket
|
|
||||||
firstIndex = index
|
|
||||||
lastIndex = index
|
|
||||||
minimumIndex = index
|
|
||||||
maximumIndex = index
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
lastIndex = index
|
|
||||||
if (point.y < points[minimumIndex].y) minimumIndex = index
|
|
||||||
if (point.y > points[maximumIndex].y) maximumIndex = index
|
|
||||||
}
|
|
||||||
flushBucket()
|
|
||||||
pushUniquePoint(result, points[end - 1])
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
90
src/core/renderingStrategies.test.ts
Normal file
90
src/core/renderingStrategies.test.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import type { WaveformPoint } from '../types'
|
||||||
|
import { DEFAULT_WAVEFORM_RENDERING_OPTIONS } from './renderingOptions'
|
||||||
|
import {
|
||||||
|
peakPreservingPointSelectionStrategy,
|
||||||
|
resolveRenderablePointSelectionStrategy,
|
||||||
|
type RenderablePointSelectionContext,
|
||||||
|
} from './renderingStrategies'
|
||||||
|
|
||||||
|
const denseOptions = {
|
||||||
|
...DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||||
|
downsampleThreshold: 0,
|
||||||
|
maxPointsPerPixel: 1,
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('renderable point selection strategies', () => {
|
||||||
|
it('resolves complete-point selection at the configured boundaries', () => {
|
||||||
|
const context: RenderablePointSelectionContext = {
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 5 },
|
||||||
|
{ x: 1, y: 1 },
|
||||||
|
{ x: 2, y: 10 },
|
||||||
|
{ x: 3, y: 3 },
|
||||||
|
{ x: 4, y: 7 },
|
||||||
|
],
|
||||||
|
range: { start: 0, end: 5 },
|
||||||
|
domain: [0, 4],
|
||||||
|
width: 100,
|
||||||
|
options: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||||
|
}
|
||||||
|
const complete = resolveRenderablePointSelectionStrategy({
|
||||||
|
visibleCount: 100,
|
||||||
|
width: 100,
|
||||||
|
options: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
|
||||||
|
})(context)
|
||||||
|
const disabled = resolveRenderablePointSelectionStrategy({
|
||||||
|
visibleCount: 10_000,
|
||||||
|
width: 100,
|
||||||
|
options: { ...denseOptions, downsample: false },
|
||||||
|
})(context)
|
||||||
|
|
||||||
|
expect(complete).toEqual(context.points)
|
||||||
|
expect(disabled).toEqual(context.points)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves peak-preserving selection for dense visible data', () => {
|
||||||
|
const context: RenderablePointSelectionContext = {
|
||||||
|
points: [
|
||||||
|
{ x: 0, y: 5 },
|
||||||
|
{ x: 1, y: 1 },
|
||||||
|
{ x: 2, y: 10 },
|
||||||
|
{ x: 3, y: 3 },
|
||||||
|
{ x: 4, y: 7 },
|
||||||
|
],
|
||||||
|
range: { start: 0, end: 5 },
|
||||||
|
domain: [0, 4],
|
||||||
|
width: 4,
|
||||||
|
options: denseOptions,
|
||||||
|
}
|
||||||
|
const selected = resolveRenderablePointSelectionStrategy({
|
||||||
|
visibleCount: 1_000,
|
||||||
|
width: 4,
|
||||||
|
options: denseOptions,
|
||||||
|
})(context)
|
||||||
|
|
||||||
|
expect(selected).toEqual(expect.arrayContaining([context.points[1], context.points[2]]))
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retains first, last, minimum, and maximum points in a peak bucket', () => {
|
||||||
|
const points: WaveformPoint[] = [
|
||||||
|
{ x: 0, y: 5 },
|
||||||
|
{ x: 1, y: 1 },
|
||||||
|
{ x: 2, y: 10 },
|
||||||
|
{ x: 3, y: 3 },
|
||||||
|
{ x: 4, y: 7 },
|
||||||
|
]
|
||||||
|
const selected = peakPreservingPointSelectionStrategy({
|
||||||
|
points,
|
||||||
|
range: { start: 0, end: points.length },
|
||||||
|
domain: [0, 4],
|
||||||
|
width: 4,
|
||||||
|
options: denseOptions,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(selected[0]).toBe(points[0])
|
||||||
|
expect(selected.at(-1)).toBe(points.at(-1))
|
||||||
|
expect(selected.map((point) => point.y)).toEqual(expect.arrayContaining([1, 10]))
|
||||||
|
})
|
||||||
|
})
|
||||||
131
src/core/renderingStrategies.ts
Normal file
131
src/core/renderingStrategies.ts
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import type { WaveformPoint } from '../types'
|
||||||
|
import type { ResolvedWaveformRenderingOptions } from './renderingOptions'
|
||||||
|
|
||||||
|
export interface VisiblePointRange {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderablePointSelectionContext {
|
||||||
|
points: WaveformPoint[]
|
||||||
|
range: VisiblePointRange
|
||||||
|
domain: [number, number]
|
||||||
|
width: number
|
||||||
|
options: ResolvedWaveformRenderingOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RenderablePointSelectionStrategy = (
|
||||||
|
context: RenderablePointSelectionContext,
|
||||||
|
) => WaveformPoint[]
|
||||||
|
|
||||||
|
function selectionBounds(range: VisiblePointRange, pointCount: number) {
|
||||||
|
return {
|
||||||
|
start: Math.max(0, range.start - 1),
|
||||||
|
end: Math.min(pointCount, range.end + 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefined) {
|
||||||
|
if (point && target[target.length - 1] !== point) target.push(point)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const completePointSelectionStrategy: RenderablePointSelectionStrategy = (context) => {
|
||||||
|
const { start, end } = selectionBounds(context.range, context.points.length)
|
||||||
|
return context.points.slice(start, end)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const peakPreservingPointSelectionStrategy: RenderablePointSelectionStrategy = (context) => {
|
||||||
|
const { points, range, domain, width, options } = context
|
||||||
|
const { start, end } = selectionBounds(range, points.length)
|
||||||
|
const visibleCount = end - start
|
||||||
|
if (visibleCount <= 0) return []
|
||||||
|
|
||||||
|
const domainStart = Math.min(domain[0], domain[1])
|
||||||
|
const domainEnd = Math.max(domain[0], domain[1])
|
||||||
|
const maximumPointCount = Math.max(4, Math.floor(width * options.maxPointsPerPixel))
|
||||||
|
const bucketCount = Math.max(1, Math.floor(maximumPointCount / 4))
|
||||||
|
const result: WaveformPoint[] = []
|
||||||
|
const span = domainEnd - domainStart || 1
|
||||||
|
const bucketIndexes = Array.from({ length: 4 }, () => -1)
|
||||||
|
let activeBucket = -1
|
||||||
|
let firstIndex = -1
|
||||||
|
let lastIndex = -1
|
||||||
|
let minimumIndex = -1
|
||||||
|
let maximumIndex = -1
|
||||||
|
|
||||||
|
const addBucketIndex = (index: number, count: number) => {
|
||||||
|
if (index < 0) return count
|
||||||
|
for (let position = 0; position < count; position += 1) {
|
||||||
|
if (bucketIndexes[position] === index) return count
|
||||||
|
}
|
||||||
|
bucketIndexes[count] = index
|
||||||
|
return count + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const flushBucket = () => {
|
||||||
|
if (firstIndex < 0) return
|
||||||
|
let count = 0
|
||||||
|
count = addBucketIndex(firstIndex, count)
|
||||||
|
count = addBucketIndex(minimumIndex, count)
|
||||||
|
count = addBucketIndex(maximumIndex, count)
|
||||||
|
count = addBucketIndex(lastIndex, count)
|
||||||
|
for (let index = 1; index < count; index += 1) {
|
||||||
|
const value = bucketIndexes[index]
|
||||||
|
let position = index - 1
|
||||||
|
while (position >= 0 && bucketIndexes[position] > value) {
|
||||||
|
bucketIndexes[position + 1] = bucketIndexes[position]
|
||||||
|
position -= 1
|
||||||
|
}
|
||||||
|
bucketIndexes[position + 1] = value
|
||||||
|
}
|
||||||
|
for (let index = 0; index < count; index += 1) {
|
||||||
|
pushUniquePoint(result, points[bucketIndexes[index]])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pushUniquePoint(result, points[start])
|
||||||
|
for (let index = range.start; index < range.end; index += 1) {
|
||||||
|
const point = points[index]
|
||||||
|
const bucket = Math.min(
|
||||||
|
bucketCount - 1,
|
||||||
|
Math.max(0, Math.floor(((point.x - domainStart) / span) * bucketCount)),
|
||||||
|
)
|
||||||
|
if (bucket !== activeBucket) {
|
||||||
|
flushBucket()
|
||||||
|
activeBucket = bucket
|
||||||
|
firstIndex = index
|
||||||
|
lastIndex = index
|
||||||
|
minimumIndex = index
|
||||||
|
maximumIndex = index
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
lastIndex = index
|
||||||
|
if (point.y < points[minimumIndex].y) minimumIndex = index
|
||||||
|
if (point.y > points[maximumIndex].y) maximumIndex = index
|
||||||
|
}
|
||||||
|
flushBucket()
|
||||||
|
pushUniquePoint(result, points[end - 1])
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RenderablePointSelectionStrategyRequest {
|
||||||
|
visibleCount: number
|
||||||
|
width: number
|
||||||
|
options: ResolvedWaveformRenderingOptions
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveRenderablePointSelectionStrategy(
|
||||||
|
request: RenderablePointSelectionStrategyRequest,
|
||||||
|
): RenderablePointSelectionStrategy {
|
||||||
|
const maximumPointCount = Math.max(
|
||||||
|
4,
|
||||||
|
Math.floor(request.width * request.options.maxPointsPerPixel),
|
||||||
|
)
|
||||||
|
const shouldUseCompletePoints =
|
||||||
|
!request.options.downsample ||
|
||||||
|
request.visibleCount <= request.options.downsampleThreshold ||
|
||||||
|
request.visibleCount <= maximumPointCount
|
||||||
|
return shouldUseCompletePoints
|
||||||
|
? completePointSelectionStrategy
|
||||||
|
: peakPreservingPointSelectionStrategy
|
||||||
|
}
|
||||||
@@ -6,13 +6,12 @@ const END_TIME = 5
|
|||||||
const TWO_PI = Math.PI * 2
|
const TWO_PI = Math.PI * 2
|
||||||
|
|
||||||
type SignalGenerator = (time: number, noise: number) => number
|
type SignalGenerator = (time: number, noise: number) => number
|
||||||
type ErrorGenerator = (time: number, value: number) => Pick<
|
type ErrorGenerator = (
|
||||||
WaveformPoint,
|
time: number,
|
||||||
'error' | 'lowerError' | 'upperError'
|
value: number,
|
||||||
>
|
) => Pick<WaveformPoint, 'error' | 'lowerError' | 'upperError'>
|
||||||
|
|
||||||
interface SimulatedSeriesDefinition
|
interface SimulatedSeriesDefinition extends Pick<
|
||||||
extends Pick<
|
|
||||||
WaveformSeries,
|
WaveformSeries,
|
||||||
'id' | 'name' | 'unit' | 'lineType' | 'pointType' | 'errorBar'
|
'id' | 'name' | 'unit' | 'lineType' | 'pointType' | 'errorBar'
|
||||||
> {
|
> {
|
||||||
@@ -39,7 +38,7 @@ function createPoints(
|
|||||||
return {
|
return {
|
||||||
x: time,
|
x: time,
|
||||||
y: value,
|
y: value,
|
||||||
...(errors?.(time, value) ?? {}),
|
...errors?.(time, value),
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,8 +22,7 @@ defineExpose({ resetViewport })
|
|||||||
v-model:annotations="model.annotations"
|
v-model:annotations="model.annotations"
|
||||||
v-model:hidden-series-ids="model.hiddenSeriesIds"
|
v-model:hidden-series-ids="model.hiddenSeriesIds"
|
||||||
:data="model.data"
|
:data="model.data"
|
||||||
:min-zoom-span="model.minZoomSpan"
|
:min-visible-points="2"
|
||||||
:min-visible-points="5"
|
|
||||||
:initial-x-domain="model.initialXDomain"
|
:initial-x-domain="model.initialXDomain"
|
||||||
:display-mode="model.displayMode"
|
:display-mode="model.displayMode"
|
||||||
:overlay-mode="model.overlayMode"
|
:overlay-mode="model.overlayMode"
|
||||||
@@ -45,6 +44,7 @@ defineExpose({ resetViewport })
|
|||||||
:clean-view="model.cleanView"
|
:clean-view="model.cleanView"
|
||||||
:presentation-mode="model.presentationMode"
|
:presentation-mode="model.presentationMode"
|
||||||
:show-tooltip="model.showTooltip"
|
:show-tooltip="model.showTooltip"
|
||||||
|
:plot-margin="model.plotMargin"
|
||||||
:zero-line="model.zeroLine"
|
:zero-line="model.zeroLine"
|
||||||
:frame-number="model.frameWatermarkVisible ? 1 : undefined"
|
:frame-number="model.frameWatermarkVisible ? 1 : undefined"
|
||||||
:annotations-visible="model.annotationsVisible"
|
:annotations-visible="model.annotationsVisible"
|
||||||
|
|||||||
@@ -8,6 +8,35 @@ const model = defineModel<DemoControlPanelModel>('model', { required: true })
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<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">
|
<section class="control-section">
|
||||||
<div class="control-section__header">
|
<div class="control-section__header">
|
||||||
<h2>零值参考线</h2>
|
<h2>零值参考线</h2>
|
||||||
|
|||||||
29
src/demo/plotMarginControls.test.ts
Normal file
29
src/demo/plotMarginControls.test.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { flushPromises, mount } from '@vue/test-utils'
|
||||||
|
import { InputNumber } from 'ant-design-vue'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
|
||||||
|
import App from '../App.vue'
|
||||||
|
import { WaveformChart } from '../components'
|
||||||
|
|
||||||
|
describe('plot margin demo controls', () => {
|
||||||
|
it('updates the chart top and bottom margins from the sidebar', async () => {
|
||||||
|
const wrapper = mount(App)
|
||||||
|
await flushPromises()
|
||||||
|
const chart = wrapper.getComponent(WaveformChart)
|
||||||
|
const controls = wrapper.get('.plot-margin-controls').findAllComponents(InputNumber)
|
||||||
|
|
||||||
|
expect(controls).toHaveLength(2)
|
||||||
|
expect(controls[0]?.props('value')).toBe(18)
|
||||||
|
expect(controls[1]?.props('value')).toBe(52)
|
||||||
|
expect(chart.props('plotMargin')).toEqual({ top: 18, bottom: 52 })
|
||||||
|
|
||||||
|
controls[0]?.vm.$emit('update:value', 30)
|
||||||
|
controls[1]?.vm.$emit('update:value', 70)
|
||||||
|
await flushPromises()
|
||||||
|
|
||||||
|
expect(chart.props('plotMargin')).toEqual({ top: 30, bottom: 70 })
|
||||||
|
expect(wrapper.get('.waveform-chart').attributes('data-plot-margin-top')).toBe('30')
|
||||||
|
expect(wrapper.get('.waveform-chart').attributes('data-plot-margin-bottom')).toBe('70')
|
||||||
|
wrapper.unmount()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,6 +10,7 @@ import type {
|
|||||||
WaveformLegendPosition,
|
WaveformLegendPosition,
|
||||||
WaveformLineStyle,
|
WaveformLineStyle,
|
||||||
WaveformOverlayMode,
|
WaveformOverlayMode,
|
||||||
|
WaveformPlotMargin,
|
||||||
WaveformTitleOptions,
|
WaveformTitleOptions,
|
||||||
WaveformZeroLineOptions,
|
WaveformZeroLineOptions,
|
||||||
WaveformZoomEndPayload,
|
WaveformZoomEndPayload,
|
||||||
@@ -26,6 +27,8 @@ export interface DemoControlPanelModel {
|
|||||||
displayMode: WaveformDisplayMode
|
displayMode: WaveformDisplayMode
|
||||||
overlayMode: WaveformOverlayMode
|
overlayMode: WaveformOverlayMode
|
||||||
showTooltip: boolean
|
showTooltip: boolean
|
||||||
|
plotMarginTop: number
|
||||||
|
plotMarginBottom: number
|
||||||
cleanView: boolean
|
cleanView: boolean
|
||||||
presentationMode: boolean
|
presentationMode: boolean
|
||||||
selectedSeriesId: string
|
selectedSeriesId: string
|
||||||
@@ -83,7 +86,6 @@ export interface DemoControlPanelModel {
|
|||||||
|
|
||||||
export interface DemoChartModel {
|
export interface DemoChartModel {
|
||||||
data: WaveformData
|
data: WaveformData
|
||||||
minZoomSpan?: number
|
|
||||||
initialXDomain?: [number, number]
|
initialXDomain?: [number, number]
|
||||||
displayMode: WaveformDisplayMode
|
displayMode: WaveformDisplayMode
|
||||||
overlayMode: WaveformOverlayMode
|
overlayMode: WaveformOverlayMode
|
||||||
@@ -99,6 +101,7 @@ export interface DemoChartModel {
|
|||||||
cleanView: boolean
|
cleanView: boolean
|
||||||
presentationMode: boolean
|
presentationMode: boolean
|
||||||
showTooltip: boolean
|
showTooltip: boolean
|
||||||
|
plotMargin: WaveformPlotMargin
|
||||||
zeroLine: WaveformZeroLineOptions
|
zeroLine: WaveformZeroLineOptions
|
||||||
frameWatermarkVisible: boolean
|
frameWatermarkVisible: boolean
|
||||||
annotations: WaveformAnnotation[]
|
annotations: WaveformAnnotation[]
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export type {
|
|||||||
WaveformAnnotationStyle,
|
WaveformAnnotationStyle,
|
||||||
WaveformAnnotation,
|
WaveformAnnotation,
|
||||||
WaveformRenderingOptions,
|
WaveformRenderingOptions,
|
||||||
|
WaveformPlotMargin,
|
||||||
WaveformTitleTextStyle,
|
WaveformTitleTextStyle,
|
||||||
WaveformTitleOptions,
|
WaveformTitleOptions,
|
||||||
WaveformLegendPosition,
|
WaveformLegendPosition,
|
||||||
|
|||||||
@@ -195,6 +195,7 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.frame-style-controls,
|
.frame-style-controls,
|
||||||
|
.plot-margin-controls,
|
||||||
.auxiliary-style-controls {
|
.auxiliary-style-controls {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ export interface WaveformRenderingOptions {
|
|||||||
errorBarMinSpacing?: number
|
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. */
|
/** Text styling for the chart-level title. */
|
||||||
export interface WaveformTitleTextStyle {
|
export interface WaveformTitleTextStyle {
|
||||||
color?: string
|
color?: string
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export type {
|
|||||||
WaveformAnnotationStyle,
|
WaveformAnnotationStyle,
|
||||||
WaveformAnnotation,
|
WaveformAnnotation,
|
||||||
WaveformRenderingOptions,
|
WaveformRenderingOptions,
|
||||||
|
WaveformPlotMargin,
|
||||||
WaveformTitleTextStyle,
|
WaveformTitleTextStyle,
|
||||||
WaveformTitleOptions,
|
WaveformTitleOptions,
|
||||||
WaveformLegendPosition,
|
WaveformLegendPosition,
|
||||||
|
|||||||
@@ -40,10 +40,11 @@ describe('waveform number formatters', () => {
|
|||||||
expect(formatScientificAxisExponent(0.0001, 0.0003)).toBe('E-04')
|
expect(formatScientificAxisExponent(0.0001, 0.0003)).toBe('E-04')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('derives the exponent from Math.max(axisMin, axisMax)', () => {
|
it('derives the exponent from the largest absolute endpoint', () => {
|
||||||
expect(resolveScientificAxisExponent(-9000, -1000)).toBe(3)
|
expect(resolveScientificAxisExponent(-9000, -1000)).toBe(3)
|
||||||
expect(resolveScientificAxisExponent(-10_000, 3000)).toBe(3)
|
expect(resolveScientificAxisExponent(-100_000, -3000)).toBe(5)
|
||||||
expect(resolveScientificAxisExponent(-1000, 0)).toBeNull()
|
expect(resolveScientificAxisExponent(-10_000, 3000)).toBe(4)
|
||||||
|
expect(resolveScientificAxisExponent(-1000, 0)).toBe(3)
|
||||||
expect(resolveScientificAxisExponent(0, 0)).toBeNull()
|
expect(resolveScientificAxisExponent(0, 0)).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,7 @@ export function shouldUseScientificAxisLabel(maxAbsoluteValue: number): boolean
|
|||||||
export function resolveScientificAxisExponent(axisMin?: number, axisMax?: number): number | null {
|
export function resolveScientificAxisExponent(axisMin?: number, axisMax?: number): number | null {
|
||||||
if (typeof axisMin !== 'number' || typeof axisMax !== 'number') return null
|
if (typeof axisMin !== 'number' || typeof axisMax !== 'number') return null
|
||||||
|
|
||||||
const maxValue = Math.max(axisMin, axisMax)
|
const absoluteMaxValue = Math.max(Math.abs(axisMin), Math.abs(axisMax))
|
||||||
const absoluteMaxValue = Math.abs(maxValue)
|
|
||||||
return shouldUseScientificAxisLabel(absoluteMaxValue)
|
return shouldUseScientificAxisLabel(absoluteMaxValue)
|
||||||
? Math.floor(Math.log10(absoluteMaxValue))
|
? Math.floor(Math.log10(absoluteMaxValue))
|
||||||
: null
|
: null
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ export function downsampleLTTB(data: WaveformPoint[], threshold: number): Wavefo
|
|||||||
|
|
||||||
// 确保阈值至少为 3
|
// 确保阈值至少为 3
|
||||||
const sampledLength = Math.max(3, Math.floor(threshold))
|
const sampledLength = Math.max(3, Math.floor(threshold))
|
||||||
const sampled: WaveformPoint[] = new Array(sampledLength)
|
const sampled: WaveformPoint[] = []
|
||||||
|
sampled.length = sampledLength
|
||||||
|
|
||||||
// 始终保留第一个和最后一个点
|
// 始终保留第一个和最后一个点
|
||||||
sampled[0] = data[0]!
|
sampled[0] = data[0]!
|
||||||
|
|||||||
Reference in New Issue
Block a user