diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 57f7a23..443e87f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..0bbc96b --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "env": { + "browser": true, + "node": true + }, + "ignorePatterns": ["dist/**", "dist-demo/**", "coverage/**", "node_modules/**"] +} diff --git a/README.md b/README.md index 20948fd..639cbee 100644 --- a/README.md +++ b/README.md @@ -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/` 均为生成目录,不要手工编辑。 diff --git a/docs/architecture.md b/docs/architecture.md index 5813268..ff96032 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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. diff --git a/package.json b/package.json index 570b38f..ce371be 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5cd7021..fc15dab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/src/components/core/useWaveformChartController.ts b/src/components/core/useWaveformChartController.ts index d0aace1..ef882d2 100644 --- a/src/components/core/useWaveformChartController.ts +++ b/src/components/core/useWaveformChartController.ts @@ -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(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, diff --git a/src/components/interaction/WaveformTooltip.test.ts b/src/components/interaction/WaveformTooltip.test.ts index 38489bf..4dbf71c 100644 --- a/src/components/interaction/WaveformTooltip.test.ts +++ b/src/components/interaction/WaveformTooltip.test.ts @@ -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, { diff --git a/src/components/interaction/WaveformTooltip.vue b/src/components/interaction/WaveformTooltip.vue index 79fc3d9..9bd7a5f 100644 --- a/src/components/interaction/WaveformTooltip.vue +++ b/src/components/interaction/WaveformTooltip.vue @@ -33,24 +33,68 @@ const props = defineProps() 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,11 +120,13 @@ function formatError(point: WaveformPoint): string | null { class="waveform-tooltip__series waveform-chart__tooltip-series" > - {{ seriesPoint.name }}: - - {{ formatTooltipNumber(seriesPoint.point.y) - }}{{ seriesPoint.unit ? ` ${seriesPoint.unit}` : '' }} - {{ formatError(seriesPoint.point) }} + + {{ seriesPoint.name }}: + + {{ formatTooltipNumber(seriesPoint.point.y) + }}{{ seriesPoint.unit ? ` ${seriesPoint.unit}` : '' }} + {{ formatError(seriesPoint.point) }} + @@ -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; } diff --git a/src/components/interaction/pointerCapture.ts b/src/components/interaction/pointerCapture.ts new file mode 100644 index 0000000..3108018 --- /dev/null +++ b/src/components/interaction/pointerCapture.ts @@ -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. + } +} diff --git a/src/components/interaction/useWaveformViewport.ts b/src/components/interaction/useWaveformViewport.ts index 0226b4c..8c6b790 100644 --- a/src/components/interaction/useWaveformViewport.ts +++ b/src/components/interaction/useWaveformViewport.ts @@ -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 - viewportInteraction: ShallowRef spacePressed: Ref trackLayouts: ComputedRef chartTracks: ComputedRef @@ -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() - 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,19 +159,22 @@ export function useWaveformViewport(context: ViewportContext) { const [rawX, rawY] = pointer(event, overlay) const x = Math.max(0, Math.min(independent ? track.width : innerWidth.value, rawX)) const y = Math.max(0, Math.min(independent ? track.height : innerHeight.value, rawY)) - const started = viewportInteraction.value.begin({ - trackIndex, - independent, - startX: x, - startY: y, - pointerId: event.pointerId, - kind: panRequested ? 'pan' : 'box', - xDomain: track.xScale.domain() as [number, number], - yDomains: currentYDomains(), + const started = transitionViewportInteraction(selection.value, { + type: 'begin', + gesture: { + trackIndex, + independent, + startX: x, + startY: y, + pointerId: event.pointerId, + 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, diff --git a/src/components/interaction/viewportInteractionState.test.ts b/src/components/interaction/viewportInteractionState.test.ts index 36fdccb..36eb3c6 100644 --- a/src/components/interaction/viewportInteractionState.test.ts +++ b/src/components/interaction/viewportInteractionState.test.ts @@ -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 }) - }) - - 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) - }) - - 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, +describe('viewport interaction reducer', () => { + it('accepts one begin and rejects a conflicting begin', () => { + const started = transitionViewportInteraction(null, { type: 'begin', gesture }) + const conflicting = transitionViewportInteraction(started.state, { + type: 'begin', + gesture: { ...gesture, pointerId: 8 }, }) - expect(machine.finish(7, { currentX: 50, currentY: 60 })).toMatchObject({ - kind: 'pan', - currentX: 50, - currentY: 60, + + expect(started.accepted).toBe(true) + expect(started.state).toMatchObject({ kind: 'box', pointerId: 7 }) + expect(conflicting.accepted).toBe(false) + expect(conflicting.state).toMatchObject({ kind: 'box', pointerId: 7 }) + }) + + it('rejects move and finish from a different pointer without changing state', () => { + const state = reduceViewportInteraction(null, { type: 'begin', gesture }) + const move = transitionViewportInteraction(state, { + type: 'move', + pointerId: 8, + position: { currentX: 30, currentY: 40 }, }) - expect(machine.state).toBeNull() + const finish = transitionViewportInteraction(move.state, { + type: 'finish', + pointerId: 8, + position: { currentX: 30, currentY: 40 }, + }) + + expect(move.accepted).toBe(false) + expect(finish.accepted).toBe(false) + expect(finish.state).toMatchObject({ currentX: 10, currentY: 20 }) }) - it('only cancels the owning pointer and supports explicit reset', () => { - const machine = new ViewportInteractionStateMachine() - machine.begin(gesture) + 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.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(moved.state).toMatchObject({ kind: 'pan', currentX: 30, currentY: 40 }) + expect(finished.completed).toMatchObject({ kind: 'pan', currentX: 50, currentY: 60 }) + expect(finished.state).toBeNull() }) - 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('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(machine.state).toMatchObject({ currentX: 10, xDomain: [0, 100] }) - expect(machine.state?.yDomains.track).toEqual([-1, 1]) + 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]) }) }) diff --git a/src/components/interaction/viewportInteractionState.ts b/src/components/interaction/viewportInteractionState.ts index e7ba1cd..4ef64de 100644 --- a/src/components/interaction/viewportInteractionState.ts +++ b/src/components/interaction/viewportInteractionState.ts @@ -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], + ...input, + currentX: input.startX, + currentY: input.startY, + xDomain: [...input.xDomain], yDomains: Object.fromEntries( - Object.entries(state.yDomains).map(([key, domain]) => [key, [...domain] as [number, number]]), + Object.entries(input.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 +function withPosition( + state: ViewportSelectionState, + position: ViewportGesturePosition, +): ViewportSelectionState { + return { ...state, ...position } +} - get state(): ViewportSelectionState | null { - return cloneState(this.current) - } +function rejectedTransition(state: ViewportSelectionState | null): ViewportInteractionTransition { + return { state, accepted: false, completed: null } +} - 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], - ]), - ), - } - 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 - } - - 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 - } - - cancel(pointerId?: number): boolean { - if (!this.current || (pointerId !== undefined && this.current.pointerId !== pointerId)) { - return false - } - this.current = null - return true - } - - reset(): void { - this.current = null +export function transitionViewportInteraction( + state: ViewportSelectionState | null, + event: ViewportInteractionEvent, +): ViewportInteractionTransition { + switch (event.type) { + case 'begin': + return state + ? rejectedTransition(state) + : { state: createSelectionState(event.gesture), accepted: true, completed: null } + case 'move': + if (!state || state.pointerId !== event.pointerId) return rejectedTransition(state) + return { state: withPosition(state, event.position), accepted: true, completed: null } + case 'finish': + if (!state || state.pointerId !== event.pointerId) return rejectedTransition(state) + return { + state: null, + accepted: true, + completed: withPosition(state, event.position), + } + case 'cancel': + if (!state || (event.pointerId !== undefined && state.pointerId !== event.pointerId)) { + return rejectedTransition(state) + } + return { state: null, accepted: true, completed: null } + case 'reset': + return { state: null, accepted: true, completed: null } } } + +export function reduceViewportInteraction( + state: ViewportSelectionState | null, + event: ViewportInteractionEvent, +): ViewportSelectionState | null { + return transitionViewportInteraction(state, event).state +} diff --git a/src/components/waveformChartCases/viewportLifecycle.test.ts b/src/components/waveformChartCases/viewportLifecycle.test.ts new file mode 100644 index 0000000..1abd5fc --- /dev/null +++ b/src/components/waveformChartCases/viewportLifecycle.test.ts @@ -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) + }) +}) diff --git a/src/core/rendering.ts b/src/core/rendering.ts index bb323ff..759304a 100644 --- a/src/core/rendering.ts +++ b/src/core/rendering.ts @@ -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, diff --git a/src/core/renderingStrategies.test.ts b/src/core/renderingStrategies.test.ts index 933cd3d..e3a4199 100644 --- a/src/core/renderingStrategies.test.ts +++ b/src/core/renderingStrategies.test.ts @@ -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], diff --git a/src/core/renderingStrategies.ts b/src/core/renderingStrategies.ts index 7e71ee1..e233905 100644 --- a/src/core/renderingStrategies.ts +++ b/src/core/renderingStrategies.ts @@ -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,91 +29,83 @@ 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[] { - const { start, end } = selectionBounds(context.range, context.points.length) - return context.points.slice(start, end) - } +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 +export const peakPreservingPointSelectionStrategy: RenderablePointSelectionStrategy = (context) => { + const { points, range, domain, width, options } = context + const { start, end } = selectionBounds(range, points.length) + const visibleCount = end - start + if (visibleCount <= 0) return [] - select(context: RenderablePointSelectionContext): WaveformPoint[] { - const { points, range, domain, width, options } = context - const { start, end } = selectionBounds(range, points.length) - const visibleCount = end - start - if (visibleCount <= 0) return [] + const domainStart = Math.min(domain[0], domain[1]) + const domainEnd = Math.max(domain[0], domain[1]) + const maximumPointCount = Math.max(4, Math.floor(width * options.maxPointsPerPixel)) + const bucketCount = Math.max(1, Math.floor(maximumPointCount / 4)) + const result: WaveformPoint[] = [] + const span = domainEnd - domainStart || 1 + const bucketIndexes = Array.from({ length: 4 }, () => -1) + let activeBucket = -1 + let firstIndex = -1 + let lastIndex = -1 + let minimumIndex = -1 + let maximumIndex = -1 - const domainStart = Math.min(domain[0], domain[1]) - const domainEnd = Math.max(domain[0], domain[1]) - const maximumPointCount = Math.max(4, Math.floor(width * options.maxPointsPerPixel)) - const bucketCount = Math.max(1, Math.floor(maximumPointCount / 4)) - const result: WaveformPoint[] = [] - const span = domainEnd - domainStart || 1 - const bucketIndexes = Array.from({ length: 4 }, () => -1) - let activeBucket = -1 - let firstIndex = -1 - let lastIndex = -1 - let minimumIndex = -1 - let maximumIndex = -1 - - const addBucketIndex = (index: number, count: number) => { - if (index < 0) return count - for (let position = 0; position < count; position += 1) { - if (bucketIndexes[position] === index) return count - } - bucketIndexes[count] = index - return count + 1 + const addBucketIndex = (index: number, count: number) => { + if (index < 0) return count + for (let position = 0; position < count; position += 1) { + if (bucketIndexes[position] === index) return count } - - const flushBucket = () => { - if (firstIndex < 0) return - let count = 0 - count = addBucketIndex(firstIndex, count) - count = addBucketIndex(minimumIndex, count) - count = addBucketIndex(maximumIndex, count) - count = addBucketIndex(lastIndex, count) - for (let index = 1; index < count; index += 1) { - const value = bucketIndexes[index] - let position = index - 1 - while (position >= 0 && bucketIndexes[position] > value) { - bucketIndexes[position + 1] = bucketIndexes[position] - position -= 1 - } - bucketIndexes[position + 1] = value - } - for (let index = 0; index < count; index += 1) { - pushUniquePoint(result, points[bucketIndexes[index]]) - } - } - - pushUniquePoint(result, points[start]) - for (let index = range.start; index < range.end; index += 1) { - const point = points[index] - const bucket = Math.min( - bucketCount - 1, - Math.max(0, Math.floor(((point.x - domainStart) / span) * bucketCount)), - ) - if (bucket !== activeBucket) { - flushBucket() - activeBucket = bucket - firstIndex = index - lastIndex = index - minimumIndex = index - maximumIndex = index - continue - } - lastIndex = index - if (point.y < points[minimumIndex].y) minimumIndex = index - if (point.y > points[maximumIndex].y) maximumIndex = index - } - flushBucket() - pushUniquePoint(result, points[end - 1]) - return result + bucketIndexes[count] = index + return count + 1 } + + const flushBucket = () => { + if (firstIndex < 0) return + let count = 0 + count = addBucketIndex(firstIndex, count) + count = addBucketIndex(minimumIndex, count) + count = addBucketIndex(maximumIndex, count) + count = addBucketIndex(lastIndex, count) + for (let index = 1; index < count; index += 1) { + const value = bucketIndexes[index] + let position = index - 1 + while (position >= 0 && bucketIndexes[position] > value) { + bucketIndexes[position + 1] = bucketIndexes[position] + position -= 1 + } + bucketIndexes[position + 1] = value + } + for (let index = 0; index < count; index += 1) { + pushUniquePoint(result, points[bucketIndexes[index]]) + } + } + + pushUniquePoint(result, points[start]) + for (let index = range.start; index < range.end; index += 1) { + const point = points[index] + const bucket = Math.min( + bucketCount - 1, + Math.max(0, Math.floor(((point.x - domainStart) / span) * bucketCount)), + ) + if (bucket !== activeBucket) { + flushBucket() + activeBucket = bucket + firstIndex = index + lastIndex = index + minimumIndex = index + maximumIndex = index + continue + } + lastIndex = index + if (point.y < points[minimumIndex].y) minimumIndex = index + if (point.y > points[maximumIndex].y) maximumIndex = index + } + flushBucket() + pushUniquePoint(result, points[end - 1]) + return result } export interface RenderablePointSelectionStrategyRequest { @@ -123,27 +114,18 @@ export interface RenderablePointSelectionStrategyRequest { options: ResolvedWaveformRenderingOptions } -export class RenderablePointSelectionStrategyFactory { - private readonly complete = new CompletePointSelectionStrategy() - private readonly peakPreserving = new PeakPreservingPointSelectionStrategy() - - resolve(request: RenderablePointSelectionStrategyRequest): RenderablePointSelectionStrategy { - const maximumPointCount = Math.max( - 4, - Math.floor(request.width * request.options.maxPointsPerPixel), - ) - const shouldUseCompletePoints = - !request.options.downsample || - request.visibleCount <= request.options.downsampleThreshold || - request.visibleCount <= maximumPointCount - return shouldUseCompletePoints ? this.complete : this.peakPreserving - } -} - -const defaultRenderablePointSelectionStrategyFactory = new RenderablePointSelectionStrategyFactory() - export function resolveRenderablePointSelectionStrategy( request: RenderablePointSelectionStrategyRequest, ): RenderablePointSelectionStrategy { - return defaultRenderablePointSelectionStrategyFactory.resolve(request) + const maximumPointCount = Math.max( + 4, + Math.floor(request.width * request.options.maxPointsPerPixel), + ) + const shouldUseCompletePoints = + !request.options.downsample || + request.visibleCount <= request.options.downsampleThreshold || + request.visibleCount <= maximumPointCount + return shouldUseCompletePoints + ? completePointSelectionStrategy + : peakPreservingPointSelectionStrategy } diff --git a/src/data/simulatedWaveforms.ts b/src/data/simulatedWaveforms.ts index bd406bd..0823b44 100644 --- a/src/data/simulatedWaveforms.ts +++ b/src/data/simulatedWaveforms.ts @@ -6,16 +6,15 @@ const END_TIME = 5 const TWO_PI = Math.PI * 2 type SignalGenerator = (time: number, noise: number) => number -type ErrorGenerator = (time: number, value: number) => Pick< - WaveformPoint, - 'error' | 'lowerError' | 'upperError' -> +type ErrorGenerator = ( + time: number, + value: number, +) => Pick -interface SimulatedSeriesDefinition - extends Pick< - WaveformSeries, - 'id' | 'name' | 'unit' | 'lineType' | 'pointType' | 'errorBar' - > { +interface SimulatedSeriesDefinition extends Pick< + WaveformSeries, + 'id' | 'name' | 'unit' | 'lineType' | 'pointType' | 'errorBar' +> { signal: SignalGenerator errors?: ErrorGenerator } @@ -39,7 +38,7 @@ function createPoints( return { x: time, y: value, - ...(errors?.(time, value) ?? {}), + ...errors?.(time, value), } }) } diff --git a/src/utils/formatters.test.ts b/src/utils/formatters.test.ts index d2a234d..96540cb 100644 --- a/src/utils/formatters.test.ts +++ b/src/utils/formatters.test.ts @@ -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() }) diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts index 076aff9..ef638ee 100644 --- a/src/utils/formatters.ts +++ b/src/utils/formatters.ts @@ -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 diff --git a/src/utils/sampling.ts b/src/utils/sampling.ts index 51dd808..35ec295 100644 --- a/src/utils/sampling.ts +++ b/src/utils/sampling.ts @@ -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]!