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 typecheck
- run: pnpm check:file-length
- run: pnpm lint:oxlint
- run: pnpm lint
- run: pnpm test:coverage
- run: pnpm build

8
.oxlintrc.json Normal file
View File

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

View File

@@ -668,12 +668,20 @@ pnpm dev
```bash
pnpm typecheck
pnpm lint:oxlint
pnpm lint
pnpm lint:all
pnpm format:check
pnpm test
pnpm test:coverage
pnpm build
```
`pnpm lint:oxlint` 使用 Oxlint 的默认 correctness 检查及内置 TypeScript、Unicorn 和 Oxc
插件,自动忽略 `dist/``dist-demo/``coverage/``node_modules/``pnpm lint` 继续负责
ESLint 的 Vue SFC、TypeScript ESLint 和 `max-lines` 规则;`pnpm lint:all` 会依次运行两者。
`pnpm format:check` 只读检查 Prettier 格式,`pnpm format` 保持原有的写入行为。
`pnpm build` 同时生成 `dist/` 组件库产物和 `dist-demo/` 演示应用。正式公开入口为
`src/index.ts`,样式入口为 `src/styles.css``dist/``dist-demo/` 均为生成目录,不要手工编辑。

View File

@@ -1,36 +1,37 @@
# Architecture
Waveform Analysis uses a hybrid architecture. Vue Composition API remains the orchestration
boundary, while classes are reserved for domain objects with lifecycle or algorithm-selection
invariants.
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. `ViewportInteractionStateMachine` is its domain collaborator: it has no Vue
or DOM dependency and accepts only legal `begin`, `move`, `finish`, `cancel`, and `reset`
transitions. Its state is a `box`/`pan` discriminated union, with `null` representing idle.
- `RenderablePointSelectionStrategy` defines the replaceable rendering algorithm boundary.
`CompletePointSelectionStrategy` preserves the complete visible source range and
`PeakPreservingPointSelectionStrategy` preserves first/minimum/maximum/last points per bucket.
`resolveRenderablePointSelectionStrategy` resolves and reuses the strategy from rendering options. The
existing `selectRenderablePoints` function remains the compatibility facade used by rendering.
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 state-machine instance is stored in `shallowRef(markRaw(...))`. Vue receives defensive state
snapshots through a shallow ref, while 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.
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 inheritance trees, global event buses, service locators, or
duplicate prop state. A new class must own a real invariant or replaceable algorithm and must be
used by a production path with isolated tests.
pure mathematical helpers. Do not add strategy factories, inheritance trees, global event buses,
service locators, or duplicate prop state. Keep pure algorithms and reducer transitions as
side-effect-free functions with isolated tests.

View File

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

217
pnpm-lock.yaml generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,65 +16,74 @@ export interface ViewportGesturePosition {
currentY: number
}
function cloneState(state: ViewportSelectionState | null): ViewportSelectionState | null {
if (!state) return null
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 {
...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,
currentX: input.startX,
currentY: input.startY,
xDomain: [...input.xDomain],
yDomains: Object.fromEntries(
Object.entries(input.yDomains).map(([key, domain]) => [
key,
[...domain] as [number, number],
]),
Object.entries(input.yDomains).map(([key, domain]) => [key, [...domain] as [number, number]]),
),
}
return true
}
move(pointerId: number, position: ViewportGesturePosition): ViewportSelectionState | null {
if (!this.current || this.current.pointerId !== pointerId) return null
this.current = { ...this.current, ...position }
return this.state
function withPosition(
state: ViewportSelectionState,
position: ViewportGesturePosition,
): ViewportSelectionState {
return { ...state, ...position }
}
finish(pointerId: number, position: ViewportGesturePosition): ViewportSelectionState | null {
if (!this.current || this.current.pointerId !== pointerId) return null
this.current = { ...this.current, ...position }
const completed = this.state
this.current = null
return completed
function rejectedTransition(state: ViewportSelectionState | null): ViewportInteractionTransition {
return { state, accepted: false, completed: null }
}
cancel(pointerId?: number): boolean {
if (!this.current || (pointerId !== undefined && this.current.pointerId !== pointerId)) {
return false
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 }
}
this.current = null
return true
}
reset(): void {
this.current = null
}
export function reduceViewportInteraction(
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 visibleCount = end - start
if (visibleCount <= 0) return []
return resolveRenderablePointSelectionStrategy({ visibleCount, width, options }).select({
return resolveRenderablePointSelectionStrategy({ visibleCount, width, options })({
points,
range,
domain,

View File

@@ -3,9 +3,9 @@ import { describe, expect, it } from 'vitest'
import type { WaveformPoint } from '../types'
import { DEFAULT_WAVEFORM_RENDERING_OPTIONS } from './renderingOptions'
import {
CompletePointSelectionStrategy,
PeakPreservingPointSelectionStrategy,
peakPreservingPointSelectionStrategy,
resolveRenderablePointSelectionStrategy,
type RenderablePointSelectionContext,
} from './renderingStrategies'
const denseOptions = {
@@ -16,42 +16,55 @@ const denseOptions = {
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).toBeInstanceOf(CompletePointSelectionStrategy)
expect(disabled).toBeInstanceOf(CompletePointSelectionStrategy)
expect(complete).toEqual(context.points)
expect(disabled).toEqual(context.points)
})
it('resolves peak-preserving selection for dense visible data', () => {
const strategy = resolveRenderablePointSelectionStrategy({
visibleCount: 1_000,
width: 100,
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(strategy).toBeInstanceOf(PeakPreservingPointSelectionStrategy)
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),
)
expect(selected).toEqual(expect.arrayContaining([context.points[1], context.points[2]]))
})
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: 4, y: 7 },
]
const strategy = new PeakPreservingPointSelectionStrategy()
const selected = strategy.select({
const selected = peakPreservingPointSelectionStrategy({
points,
range: { start: 0, end: points.length },
domain: [0, 4],

View File

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

View File

@@ -6,13 +6,12 @@ const END_TIME = 5
const TWO_PI = Math.PI * 2
type SignalGenerator = (time: number, noise: number) => number
type ErrorGenerator = (time: number, value: number) => Pick<
WaveformPoint,
'error' | 'lowerError' | 'upperError'
>
type ErrorGenerator = (
time: number,
value: number,
) => Pick<WaveformPoint, 'error' | 'lowerError' | 'upperError'>
interface SimulatedSeriesDefinition
extends Pick<
interface SimulatedSeriesDefinition extends Pick<
WaveformSeries,
'id' | 'name' | 'unit' | 'lineType' | 'pointType' | 'errorBar'
> {
@@ -39,7 +38,7 @@ function createPoints(
return {
x: time,
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')
})
it('derives the exponent from Math.max(axisMin, axisMax)', () => {
it('derives the exponent from the largest absolute endpoint', () => {
expect(resolveScientificAxisExponent(-9000, -1000)).toBe(3)
expect(resolveScientificAxisExponent(-10_000, 3000)).toBe(3)
expect(resolveScientificAxisExponent(-1000, 0)).toBeNull()
expect(resolveScientificAxisExponent(-100_000, -3000)).toBe(5)
expect(resolveScientificAxisExponent(-10_000, 3000)).toBe(4)
expect(resolveScientificAxisExponent(-1000, 0)).toBe(3)
expect(resolveScientificAxisExponent(0, 0)).toBeNull()
})

View File

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

View File

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