feat(chart): refine viewport interaction and rendering

This commit is contained in:
liqiyuan
2026-08-08 10:43:39 +08:00
parent 7646bf4907
commit 9b42e3797b
21 changed files with 838 additions and 325 deletions

View File

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

8
.oxlintrc.json Normal file
View File

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

View File

@@ -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/` 均为生成目录,不要手工编辑。

View File

@@ -1,36 +1,37 @@
# Architecture # Architecture
Waveform Analysis uses a hybrid architecture. Vue Composition API remains the orchestration Waveform Analysis uses a functional architecture around Vue Composition API. Vue composables own
boundary, while classes are reserved for domain objects with lifecycle or algorithm-selection reactive orchestration and DOM resources, while pure functions own stateless calculations and
invariants. state transitions.
## Boundaries ## Boundaries
- `useWaveformChartController` is the facade for the chart. It composes composables and exposes the - `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. existing reactive controller surface; it owns no independent copy of component props.
- `useWaveformViewport` keeps refs, computed values, pointer events, DOM capture, D3 coordinates, - `useWaveformViewport` keeps refs, computed values, pointer events, DOM capture, D3 coordinates,
and emitted events. `ViewportInteractionStateMachine` is its domain collaborator: it has no Vue and emitted events. `transitionViewportInteraction` and `reduceViewportInteraction` are pure
or DOM dependency and accepts only legal `begin`, `move`, `finish`, `cancel`, and `reset` reducer functions for the legal `begin`, `move`, `finish`, `cancel`, and `reset` transitions.
transitions. Its state is a `box`/`pan` discriminated union, with `null` representing idle. The composable's `selection` shallow ref is the only interaction state source; SVG overlays and
- `RenderablePointSelectionStrategy` defines the replaceable rendering algorithm boundary. pointer capture remain DOM resources local to the composable.
`CompletePointSelectionStrategy` preserves the complete visible source range and - `RenderablePointSelectionStrategy` is a function type defining the replaceable rendering
`PeakPreservingPointSelectionStrategy` preserves first/minimum/maximum/last points per bucket. algorithm boundary. `completePointSelectionStrategy` preserves the complete visible source range
`resolveRenderablePointSelectionStrategy` resolves and reuses the strategy from rendering options. The and `peakPreservingPointSelectionStrategy` preserves first/minimum/maximum/last points per
existing `selectRenderablePoints` function remains the compatibility facade used by rendering. 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 - `normalizeWaveformData` and `normalizeWaveformSeries` are functional adapters from public data
shapes to the internal series model. `buildTrackLayouts` remains a functional builder because shapes to the internal series model. `buildTrackLayouts` remains a functional builder because
layout construction is a stateless calculation, not a long-lived object. layout construction is a stateless calculation, not a long-lived object.
## Vue Integration ## Vue Integration
The state-machine instance is stored in `shallowRef(markRaw(...))`. Vue receives defensive state The viewport `selection` is stored in a shallow ref and updated only with reducer transitions. SVG
snapshots through a shallow ref, while SVG overlay elements remain in the composable as DOM overlay elements remain in the composable as DOM resources. Presentation mode, annotation editing,
resources. Presentation mode, annotation editing, scales, domains, ticks, formatting, and other scales, domains, ticks, formatting, and other stateless calculations stay in their existing
stateless calculations stay in their existing computed/composable or function boundaries. computed/composable or function boundaries.
## Constraints ## Constraints
Do not create classes solely to wrap Composition API refs, props, lifecycle hooks, D3 selections, or Do not create classes solely to wrap Composition API refs, props, lifecycle hooks, D3 selections, or
pure mathematical helpers. Do not add inheritance trees, global event buses, service locators, or pure mathematical helpers. Do not add strategy factories, inheritance trees, global event buses,
duplicate prop state. A new class must own a real invariant or replaceable algorithm and must be service locators, or duplicate prop state. Keep pure algorithms and reducer transitions as
used by a production path with isolated tests. side-effect-free functions with isolated tests.

View File

@@ -31,7 +31,10 @@
"typecheck": "vue-tsc -b", "typecheck": "vue-tsc -b",
"check:file-length": "node scripts/check-file-length.mjs", "check:file-length": "node scripts/check-file-length.mjs",
"lint": "eslint . --max-warnings=0", "lint": "eslint . --max-warnings=0",
"lint:oxlint": "oxlint . --deny-warnings",
"lint:all": "pnpm lint:oxlint && pnpm lint",
"format": "prettier --write .", "format": "prettier --write .",
"format:check": "prettier --check .",
"test": "vitest run", "test": "vitest run",
"test:coverage": "vitest run --coverage" "test:coverage": "vitest run --coverage"
}, },
@@ -57,6 +60,7 @@
"eslint-plugin-vue": "10.9.2", "eslint-plugin-vue": "10.9.2",
"globals": "17.7.0", "globals": "17.7.0",
"jsdom": "29.1.1", "jsdom": "29.1.1",
"oxlint": "1.77.0",
"prettier": "3.9.5", "prettier": "3.9.5",
"typescript": "~6.0.0", "typescript": "~6.0.0",
"typescript-eslint": "8.64.0", "typescript-eslint": "8.64.0",

217
pnpm-lock.yaml generated
View File

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

View File

@@ -18,7 +18,6 @@ import { useWaveformChartAnnotations } from '../annotation/useWaveformChartAnnot
import { useWaveformHover } from '../interaction/useWaveformHover' import { useWaveformHover } from '../interaction/useWaveformHover'
import { useWaveformViewport } from '../interaction/useWaveformViewport' import { useWaveformViewport } from '../interaction/useWaveformViewport'
import { useWaveformZoom } from '../interaction/useWaveformZoom' import { useWaveformZoom } from '../interaction/useWaveformZoom'
import { ViewportInteractionStateMachine } from '../interaction/viewportInteractionState'
import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle' import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
import { margin } from './constants' import { margin } from './constants'
import { getPageSize } from './grid' import { getPageSize } from './grid'
@@ -80,7 +79,6 @@ export function useWaveformChartController(
{ deep: true }, { deep: true },
) )
const selection = shallowRef<ViewportSelectionState | null>(null) const selection = shallowRef<ViewportSelectionState | null>(null)
const viewportInteraction = shallowRef(markRaw(new ViewportInteractionStateMachine()))
const spacePressed = ref(false) const spacePressed = ref(false)
const pointerInsideChart = ref(false) const pointerInsideChart = ref(false)
let handleDataReferenceChange: () => void = () => undefined let handleDataReferenceChange: () => void = () => undefined
@@ -195,7 +193,6 @@ export function useWaveformChartController(
props, props,
emit, emit,
selection, selection,
viewportInteraction,
spacePressed, spacePressed,
trackLayouts, trackLayouts,
chartTracks, chartTracks,

View File

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

View File

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

View File

@@ -0,0 +1,10 @@
export function tryReleasePointerCapture(
target: SVGRectElement | null | undefined,
pointerId: number,
): void {
try {
target?.releasePointerCapture?.(pointerId)
} catch {
// The target may already be detached or have released the pointer.
}
}

View File

@@ -1,6 +1,5 @@
import { pointer, scaleLinear, zoomIdentity, type ZoomTransform } from 'd3' import { pointer, scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
import { computed, nextTick, shallowRef, 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,13 +9,12 @@ import type {
WaveformChartEmit, WaveformChartEmit,
} from '../core/waveformChartTypes' } from '../core/waveformChartTypes'
import type { AnnotationSeriesCandidate } from '../annotation' import type { AnnotationSeriesCandidate } from '../annotation'
import type { ViewportInteractionStateMachine } from './viewportInteractionState' import { tryReleasePointerCapture } from './pointerCapture'
import { transitionViewportInteraction } from './viewportInteractionState'
interface ViewportContext { interface ViewportContext {
props: ResolvedWaveformChartProps props: ResolvedWaveformChartProps
emit: WaveformChartEmit emit: WaveformChartEmit
selection: Ref<ViewportSelectionState | null> selection: Ref<ViewportSelectionState | null>
viewportInteraction: ShallowRef<ViewportInteractionStateMachine>
spacePressed: Ref<boolean> spacePressed: Ref<boolean>
trackLayouts: ComputedRef<TrackLayout[]> trackLayouts: ComputedRef<TrackLayout[]>
chartTracks: ComputedRef<DisplayTrack[]> chartTracks: ComputedRef<DisplayTrack[]>
@@ -39,13 +37,11 @@ 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,
emit, emit,
selection, selection,
viewportInteraction,
spacePressed, spacePressed,
trackLayouts, trackLayouts,
chartTracks, chartTracks,
@@ -69,8 +65,21 @@ export function useWaveformViewport(context: ViewportContext) {
resolveTrackAtPointer, resolveTrackAtPointer,
} = context } = context
const activeOverlay = shallowRef<SVGRectElement>() const activeOverlay = shallowRef<SVGRectElement>()
const syncSelection = () => { const releasePointerCapture = (pointerId: number, event?: PointerEvent) => {
selection.value = viewportInteraction.value.state 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
@@ -150,7 +159,9 @@ 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))
const started = viewportInteraction.value.begin({ const started = transitionViewportInteraction(selection.value, {
type: 'begin',
gesture: {
trackIndex, trackIndex,
independent, independent,
startX: x, startX: x,
@@ -159,10 +170,11 @@ export function useWaveformViewport(context: ViewportContext) {
kind: 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) return if (!started.accepted) return
selection.value = started.state
activeOverlay.value = overlay activeOverlay.value = overlay
syncSelection()
overlay.setPointerCapture?.(event.pointerId) overlay.setPointerCapture?.(event.pointerId)
clearHover() clearHover()
event.preventDefault() event.preventDefault()
@@ -233,8 +245,13 @@ export function useWaveformViewport(context: ViewportContext) {
0, 0,
Math.min(active.independent ? track.height : innerHeight.value, rawY), Math.min(active.independent ? track.height : innerHeight.value, rawY),
) )
const next = viewportInteraction.value.move(event.pointerId, { currentX, currentY }) const transition = transitionViewportInteraction(selection.value, {
if (!next) return type: 'move',
pointerId: event.pointerId,
position: { currentX, currentY },
})
if (!transition.accepted || !transition.state) return
const next = transition.state
selection.value = next selection.value = next
if (next.kind === 'pan') applyPan(next, track) if (next.kind === 'pan') applyPan(next, track)
event.preventDefault() event.preventDefault()
@@ -242,11 +259,7 @@ export function useWaveformViewport(context: ViewportContext) {
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
activeOverlay.value?.releasePointerCapture?.(active.pointerId) cleanupViewportDrag(active.pointerId, event)
if (viewportInteraction.value.cancel(event?.pointerId)) {
activeOverlay.value = undefined
syncSelection()
}
} }
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)
@@ -314,9 +327,11 @@ 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
const track = trackLayouts.value.find((item) => item.index === active.trackIndex) const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
if (!track) return
const overlay = activeOverlay.value const overlay = activeOverlay.value
if (!overlay) return if (!track || !overlay || !overlay.parentNode) {
cleanupViewportDrag(active.pointerId, event)
return
}
const [rawX, rawY] = pointer(event, overlay) const [rawX, rawY] = pointer(event, overlay)
const currentX = Math.max( const currentX = Math.max(
0, 0,
@@ -326,11 +341,15 @@ export function useWaveformViewport(context: ViewportContext) {
0, 0,
Math.min(active.independent ? track.height : innerHeight.value, rawY), Math.min(active.independent ? track.height : innerHeight.value, rawY),
) )
const completed = viewportInteraction.value.finish(event.pointerId, { currentX, currentY }) const completed = transitionViewportInteraction(selection.value, {
type: 'finish',
pointerId: event.pointerId,
position: { currentX, currentY },
}).completed
if (!completed) return if (!completed) return
overlay.releasePointerCapture?.(completed.pointerId) selection.value = null
releasePointerCapture(completed.pointerId, event)
activeOverlay.value = undefined activeOverlay.value = undefined
syncSelection()
event.preventDefault() event.preventDefault()
if (completed.kind === 'pan') { if (completed.kind === 'pan') {
applyPan(completed, track) applyPan(completed, track)
@@ -367,7 +386,6 @@ export function useWaveformViewport(context: ViewportContext) {
resetViewport() resetViewport()
emit('zoom-reset') emit('zoom-reset')
} }
return { return {
selectionBox, selectionBox,
beginViewportDrag, beginViewportDrag,

View File

@@ -1,6 +1,10 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { ViewportInteractionStateMachine } from './viewportInteractionState' import {
reduceViewportInteraction,
transitionViewportInteraction,
type ViewportInteractionEvent,
} from './viewportInteractionState'
const gesture = { const gesture = {
trackIndex: 2, trackIndex: 2,
@@ -13,64 +17,85 @@ const gesture = {
yDomains: { track: [-1, 1] as [number, number] }, yDomains: { track: [-1, 1] as [number, number] },
} }
describe('ViewportInteractionStateMachine', () => { describe('viewport interaction reducer', () => {
it('accepts one gesture and rejects a conflicting start', () => { it('accepts one begin and rejects a conflicting begin', () => {
const machine = new ViewportInteractionStateMachine() const started = transitionViewportInteraction(null, { type: 'begin', gesture })
const conflicting = transitionViewportInteraction(started.state, {
expect(machine.begin(gesture)).toBe(true) type: 'begin',
expect(machine.begin({ ...gesture, pointerId: 8 })).toBe(false) gesture: { ...gesture, pointerId: 8 },
expect(machine.state).toMatchObject({ kind: 'box', pointerId: 7 })
}) })
it('rejects moves and completion from a different pointer', () => { expect(started.accepted).toBe(true)
const machine = new ViewportInteractionStateMachine() expect(started.state).toMatchObject({ kind: 'box', pointerId: 7 })
machine.begin(gesture) expect(conflicting.accepted).toBe(false)
expect(conflicting.state).toMatchObject({ kind: 'box', pointerId: 7 })
expect(machine.move(8, { currentX: 30, currentY: 40 })).toBeNull()
expect(machine.finish(8, { currentX: 30, currentY: 40 })).toBeNull()
expect(machine.state?.currentX).toBe(10)
}) })
it('updates a valid pointer, completes it, and returns to idle', () => { it('rejects move and finish from a different pointer without changing state', () => {
const machine = new ViewportInteractionStateMachine() const state = reduceViewportInteraction(null, { type: 'begin', gesture })
machine.begin({ ...gesture, kind: 'pan' }) const move = transitionViewportInteraction(state, {
type: 'move',
expect(machine.move(7, { currentX: 30, currentY: 40 })).toMatchObject({ pointerId: 8,
kind: 'pan', position: { currentX: 30, currentY: 40 },
currentX: 30,
currentY: 40,
}) })
expect(machine.finish(7, { currentX: 50, currentY: 60 })).toMatchObject({ const finish = transitionViewportInteraction(move.state, {
kind: 'pan', type: 'finish',
currentX: 50, pointerId: 8,
currentY: 60, position: { currentX: 30, currentY: 40 },
})
expect(machine.state).toBeNull()
}) })
it('only cancels the owning pointer and supports explicit reset', () => { expect(move.accepted).toBe(false)
const machine = new ViewportInteractionStateMachine() expect(finish.accepted).toBe(false)
machine.begin(gesture) expect(finish.state).toMatchObject({ currentX: 10, currentY: 20 })
expect(machine.cancel(8)).toBe(false)
expect(machine.state).not.toBeNull()
expect(machine.cancel(7)).toBe(true)
expect(machine.state).toBeNull()
machine.begin(gesture)
machine.reset()
expect(machine.state).toBeNull()
}) })
it('returns defensive copies from its state getter', () => { it('updates a valid pointer and returns the completed snapshot on finish', () => {
const machine = new ViewportInteractionStateMachine() const state = reduceViewportInteraction(null, {
machine.begin(gesture) type: 'begin',
const snapshot = machine.state! gesture: { ...gesture, kind: 'pan' },
snapshot.currentX = 99 })
snapshot.xDomain[0] = 50 const moved = transitionViewportInteraction(state, {
snapshot.yDomains.track![0] = 50 type: 'move',
pointerId: 7,
position: { currentX: 30, currentY: 40 },
})
const finished = transitionViewportInteraction(moved.state, {
type: 'finish',
pointerId: 7,
position: { currentX: 50, currentY: 60 },
})
expect(machine.state).toMatchObject({ currentX: 10, xDomain: [0, 100] }) expect(moved.state).toMatchObject({ kind: 'pan', currentX: 30, currentY: 40 })
expect(machine.state?.yDomains.track).toEqual([-1, 1]) expect(finished.completed).toMatchObject({ kind: 'pan', currentX: 50, currentY: 60 })
expect(finished.state).toBeNull()
})
it('cancels only the owning pointer and supports cancel/reset events', () => {
const state = reduceViewportInteraction(null, { type: 'begin', gesture })
const rejectedCancel = transitionViewportInteraction(state, { type: 'cancel', pointerId: 8 })
const cancelled = transitionViewportInteraction(rejectedCancel.state, {
type: 'cancel',
pointerId: 7,
})
const restarted = reduceViewportInteraction(null, { type: 'begin', gesture })
const reset = transitionViewportInteraction(restarted, { type: 'reset' })
expect(rejectedCancel.accepted).toBe(false)
expect(rejectedCancel.state).not.toBeNull()
expect(cancelled.accepted).toBe(true)
expect(cancelled.state).toBeNull()
expect(reset.accepted).toBe(true)
expect(reset.state).toBeNull()
})
it('keeps events discriminated and does not mutate the begin input', () => {
const event: ViewportInteractionEvent = { type: 'begin', gesture }
const state = reduceViewportInteraction(null, event)
expect(state).not.toBeNull()
expect(state?.xDomain).toEqual([0, 100])
expect(state?.yDomains.track).toEqual([-1, 1])
expect(event.gesture.xDomain).toEqual([0, 100])
expect(event.gesture.yDomains.track).toEqual([-1, 1])
}) })
}) })

View File

@@ -16,65 +16,74 @@ export interface ViewportGesturePosition {
currentY: number currentY: number
} }
function cloneState(state: ViewportSelectionState | null): ViewportSelectionState | null { export type ViewportInteractionEvent =
if (!state) return null | { 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 { return {
...state,
xDomain: [...state.xDomain],
yDomains: Object.fromEntries(
Object.entries(state.yDomains).map(([key, domain]) => [key, [...domain] as [number, number]]),
),
}
}
/** Owns the legal lifecycle of one active viewport pointer gesture. */
export class ViewportInteractionStateMachine {
private current: ViewportSelectionState | null = null
get state(): ViewportSelectionState | null {
return cloneState(this.current)
}
begin(input: ViewportGestureStart): boolean {
if (this.current) return false
this.current = {
...input, ...input,
currentX: input.startX, currentX: input.startX,
currentY: input.startY, currentY: input.startY,
xDomain: [...input.xDomain], xDomain: [...input.xDomain],
yDomains: Object.fromEntries( yDomains: Object.fromEntries(
Object.entries(input.yDomains).map(([key, domain]) => [ Object.entries(input.yDomains).map(([key, domain]) => [key, [...domain] as [number, number]]),
key,
[...domain] as [number, number],
]),
), ),
} }
return true
} }
move(pointerId: number, position: ViewportGesturePosition): ViewportSelectionState | null { function withPosition(
if (!this.current || this.current.pointerId !== pointerId) return null state: ViewportSelectionState,
this.current = { ...this.current, ...position } position: ViewportGesturePosition,
return this.state ): ViewportSelectionState {
return { ...state, ...position }
} }
finish(pointerId: number, position: ViewportGesturePosition): ViewportSelectionState | null { function rejectedTransition(state: ViewportSelectionState | null): ViewportInteractionTransition {
if (!this.current || this.current.pointerId !== pointerId) return null return { state, accepted: false, completed: null }
this.current = { ...this.current, ...position }
const completed = this.state
this.current = null
return completed
} }
cancel(pointerId?: number): boolean { export function transitionViewportInteraction(
if (!this.current || (pointerId !== undefined && this.current.pointerId !== pointerId)) { state: ViewportSelectionState | null,
return false 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 }
} }
this.current = null
return true
} }
reset(): void { export function reduceViewportInteraction(
this.current = null state: ViewportSelectionState | null,
} event: ViewportInteractionEvent,
): ViewportSelectionState | null {
return transitionViewportInteraction(state, event).state
} }

View File

@@ -0,0 +1,132 @@
import { flushPromises } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { gridSeries, mountSizedChart } from '../../test/waveformChart'
import WaveformChartView from '../WaveformChartView.vue'
describe('WaveformChart viewport lifecycle', () => {
it('cleans an active shared gesture when its track is removed', async () => {
const wrapper = await mountSizedChart(gridSeries(2), {
displayMode: 'separated',
grid: { rowCount: 2, columnCount: 1 },
})
const overlay = wrapper.get('.waveform-chart__overlay--shared')
const width = Number(overlay.attributes('width'))
const height = Number(overlay.attributes('height'))
const releasePointerCapture = vi.fn()
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height }),
})
Object.defineProperty(overlay.element, 'setPointerCapture', { value: vi.fn() })
Object.defineProperty(overlay.element, 'releasePointerCapture', {
value: releasePointerCapture,
})
const dispatchPointer = (type: string, pointerId: number, clientY: number) => {
const event = new MouseEvent(type, {
button: 0,
clientX: width / 2,
clientY,
bubbles: true,
})
Object.defineProperty(event, 'pointerId', { value: pointerId })
overlay.element.dispatchEvent(event)
}
dispatchPointer('pointerdown', 41, height * 0.75)
await flushPromises()
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(true)
await wrapper.setProps({ data: gridSeries(1) })
await flushPromises()
dispatchPointer('pointerup', 41, height * 0.75)
await flushPromises()
expect(releasePointerCapture).toHaveBeenCalledWith(41)
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(false)
dispatchPointer('pointerdown', 42, height / 2)
dispatchPointer('pointermove', 42, height / 2 + 20)
await flushPromises()
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(true)
})
it('cleans an active gesture when its overlay leaves the DOM', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
],
},
{ displayMode: 'separated' },
)
const overlay = wrapper.get('.waveform-chart__overlay--shared')
const width = Number(overlay.attributes('width'))
const height = Number(overlay.attributes('height'))
const releasePointerCapture = vi.fn(() => {
throw new DOMException('Pointer capture is no longer available', 'NotFoundError')
})
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height }),
})
Object.defineProperty(overlay.element, 'setPointerCapture', { value: vi.fn() })
Object.defineProperty(overlay.element, 'releasePointerCapture', {
value: releasePointerCapture,
})
const down = new MouseEvent('pointerdown', {
button: 0,
clientX: width / 2,
clientY: height / 2,
bubbles: true,
})
Object.defineProperty(down, 'pointerId', { value: 43 })
overlay.element.dispatchEvent(down)
await wrapper.setProps({ hiddenSeriesIds: ['series-0'] })
await flushPromises()
expect(wrapper.find('.waveform-chart__overlay--shared').exists()).toBe(false)
const view = wrapper.findComponent(WaveformChartView)
const finishViewportDrag = (
view.vm as unknown as { finishViewportDrag: (event: PointerEvent) => void }
).finishViewportDrag
const up = new MouseEvent('pointerup', {
clientX: width / 2,
clientY: height / 2,
bubbles: true,
})
Object.defineProperty(up, 'pointerId', { value: 43 })
finishViewportDrag(up as unknown as PointerEvent)
await flushPromises()
expect(releasePointerCapture).toHaveBeenCalledWith(43)
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(false)
await wrapper.setProps({ hiddenSeriesIds: [] })
await flushPromises()
const nextOverlay = wrapper.get('.waveform-chart__overlay--shared')
Object.defineProperty(nextOverlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height }),
})
const nextDown = new MouseEvent('pointerdown', {
button: 0,
clientX: width / 2,
clientY: height / 2,
bubbles: true,
})
Object.defineProperty(nextDown, 'pointerId', { value: 44 })
nextOverlay.element.dispatchEvent(nextDown)
const nextMove = new MouseEvent('pointermove', {
clientX: width * 0.75,
clientY: height / 2,
bubbles: true,
})
Object.defineProperty(nextMove, 'pointerId', { value: 44 })
nextOverlay.element.dispatchEvent(nextMove)
await flushPromises()
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(true)
})
})

View File

@@ -75,7 +75,7 @@ function selectRenderablePointsInRange(
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 []
return resolveRenderablePointSelectionStrategy({ visibleCount, width, options }).select({ return resolveRenderablePointSelectionStrategy({ visibleCount, width, options })({
points, points,
range, range,
domain, domain,

View File

@@ -3,9 +3,9 @@ import { describe, expect, it } from 'vitest'
import type { WaveformPoint } from '../types' import type { WaveformPoint } from '../types'
import { DEFAULT_WAVEFORM_RENDERING_OPTIONS } from './renderingOptions' import { DEFAULT_WAVEFORM_RENDERING_OPTIONS } from './renderingOptions'
import { import {
CompletePointSelectionStrategy, peakPreservingPointSelectionStrategy,
PeakPreservingPointSelectionStrategy,
resolveRenderablePointSelectionStrategy, resolveRenderablePointSelectionStrategy,
type RenderablePointSelectionContext,
} from './renderingStrategies' } from './renderingStrategies'
const denseOptions = { const denseOptions = {
@@ -16,42 +16,55 @@ const denseOptions = {
describe('renderable point selection strategies', () => { describe('renderable point selection strategies', () => {
it('resolves complete-point selection at the configured boundaries', () => { 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({ const complete = resolveRenderablePointSelectionStrategy({
visibleCount: 100, visibleCount: 100,
width: 100, width: 100,
options: DEFAULT_WAVEFORM_RENDERING_OPTIONS, options: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
}) })(context)
const disabled = resolveRenderablePointSelectionStrategy({ const disabled = resolveRenderablePointSelectionStrategy({
visibleCount: 10_000, visibleCount: 10_000,
width: 100, width: 100,
options: { ...denseOptions, downsample: false }, options: { ...denseOptions, downsample: false },
}) })(context)
expect(complete).toBeInstanceOf(CompletePointSelectionStrategy) expect(complete).toEqual(context.points)
expect(disabled).toBeInstanceOf(CompletePointSelectionStrategy) expect(disabled).toEqual(context.points)
}) })
it('resolves peak-preserving selection for dense visible data', () => { it('resolves peak-preserving selection for dense visible data', () => {
const strategy = resolveRenderablePointSelectionStrategy({ const context: RenderablePointSelectionContext = {
visibleCount: 1_000, points: [
width: 100, { 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, options: denseOptions,
}) }
const selected = resolveRenderablePointSelectionStrategy({
visibleCount: 1_000,
width: 4,
options: denseOptions,
})(context)
expect(strategy).toBeInstanceOf(PeakPreservingPointSelectionStrategy) expect(selected).toEqual(expect.arrayContaining([context.points[1], context.points[2]]))
expect(strategy.name).toBe('peak-preserving')
})
it('reuses the resolved strategy instance across selections', () => {
const request = { visibleCount: 100, width: 100, options: DEFAULT_WAVEFORM_RENDERING_OPTIONS }
const peakRequest = { visibleCount: 1_000, width: 100, options: denseOptions }
expect(resolveRenderablePointSelectionStrategy(request)).toBe(
resolveRenderablePointSelectionStrategy(request),
)
expect(resolveRenderablePointSelectionStrategy(peakRequest)).toBe(
resolveRenderablePointSelectionStrategy(peakRequest),
)
}) })
it('retains first, last, minimum, and maximum points in a peak bucket', () => { it('retains first, last, minimum, and maximum points in a peak bucket', () => {
@@ -62,8 +75,7 @@ describe('renderable point selection strategies', () => {
{ x: 3, y: 3 }, { x: 3, y: 3 },
{ x: 4, y: 7 }, { x: 4, y: 7 },
] ]
const strategy = new PeakPreservingPointSelectionStrategy() const selected = peakPreservingPointSelectionStrategy({
const selected = strategy.select({
points, points,
range: { start: 0, end: points.length }, range: { start: 0, end: points.length },
domain: [0, 4], domain: [0, 4],

View File

@@ -14,10 +14,9 @@ export interface RenderablePointSelectionContext {
options: ResolvedWaveformRenderingOptions options: ResolvedWaveformRenderingOptions
} }
export interface RenderablePointSelectionStrategy { export type RenderablePointSelectionStrategy = (
readonly name: 'complete' | 'peak-preserving' context: RenderablePointSelectionContext,
select(context: RenderablePointSelectionContext): WaveformPoint[] ) => WaveformPoint[]
}
function selectionBounds(range: VisiblePointRange, pointCount: number) { function selectionBounds(range: VisiblePointRange, pointCount: number) {
return { return {
@@ -30,19 +29,12 @@ function pushUniquePoint(target: WaveformPoint[], point: WaveformPoint | undefin
if (point && target[target.length - 1] !== point) target.push(point) if (point && target[target.length - 1] !== point) target.push(point)
} }
export class CompletePointSelectionStrategy implements RenderablePointSelectionStrategy { export const completePointSelectionStrategy: RenderablePointSelectionStrategy = (context) => {
readonly name = 'complete' as const
select(context: RenderablePointSelectionContext): WaveformPoint[] {
const { start, end } = selectionBounds(context.range, context.points.length) const { start, end } = selectionBounds(context.range, context.points.length)
return context.points.slice(start, end) return context.points.slice(start, end)
} }
}
export class PeakPreservingPointSelectionStrategy implements RenderablePointSelectionStrategy { export const peakPreservingPointSelectionStrategy: RenderablePointSelectionStrategy = (context) => {
readonly name = 'peak-preserving' as const
select(context: RenderablePointSelectionContext): WaveformPoint[] {
const { points, range, domain, width, options } = context const { points, range, domain, width, options } = context
const { start, end } = selectionBounds(range, points.length) const { start, end } = selectionBounds(range, points.length)
const visibleCount = end - start const visibleCount = end - start
@@ -115,7 +107,6 @@ export class PeakPreservingPointSelectionStrategy implements RenderablePointSele
pushUniquePoint(result, points[end - 1]) pushUniquePoint(result, points[end - 1])
return result return result
} }
}
export interface RenderablePointSelectionStrategyRequest { export interface RenderablePointSelectionStrategyRequest {
visibleCount: number visibleCount: number
@@ -123,11 +114,9 @@ export interface RenderablePointSelectionStrategyRequest {
options: ResolvedWaveformRenderingOptions options: ResolvedWaveformRenderingOptions
} }
export class RenderablePointSelectionStrategyFactory { export function resolveRenderablePointSelectionStrategy(
private readonly complete = new CompletePointSelectionStrategy() request: RenderablePointSelectionStrategyRequest,
private readonly peakPreserving = new PeakPreservingPointSelectionStrategy() ): RenderablePointSelectionStrategy {
resolve(request: RenderablePointSelectionStrategyRequest): RenderablePointSelectionStrategy {
const maximumPointCount = Math.max( const maximumPointCount = Math.max(
4, 4,
Math.floor(request.width * request.options.maxPointsPerPixel), Math.floor(request.width * request.options.maxPointsPerPixel),
@@ -136,14 +125,7 @@ export class RenderablePointSelectionStrategyFactory {
!request.options.downsample || !request.options.downsample ||
request.visibleCount <= request.options.downsampleThreshold || request.visibleCount <= request.options.downsampleThreshold ||
request.visibleCount <= maximumPointCount request.visibleCount <= maximumPointCount
return shouldUseCompletePoints ? this.complete : this.peakPreserving return shouldUseCompletePoints
} ? completePointSelectionStrategy
} : peakPreservingPointSelectionStrategy
const defaultRenderablePointSelectionStrategyFactory = new RenderablePointSelectionStrategyFactory()
export function resolveRenderablePointSelectionStrategy(
request: RenderablePointSelectionStrategyRequest,
): RenderablePointSelectionStrategy {
return defaultRenderablePointSelectionStrategyFactory.resolve(request)
} }

View File

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

View File

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

View File

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

View File

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