diff --git a/README.md b/README.md index 37bac82..35b3690 100644 --- a/README.md +++ b/README.md @@ -1,66 +1,104 @@ # Waveform Analysis -基于 Vue 3、TypeScript 和 D3 的响应式波形图组件库与示例项目。 +基于 Vue 3、TypeScript 和 D3 的响应式 SVG 波形图组件。适合展示单通道、多通道和大规模采样 +数据,内置缩放、tooltip、图例、误差棒、标注、分页和多 Y 轴叠加。 -组件使用 SVG 绘制坐标轴和波形;大数据会按当前可见范围和屏幕像素自动保峰降采样, -tooltip 与标注仍使用完整原始数据。 +组件使用不可变数据模型:替换 `data` 引用后会重新计算数据域和视口;大数据会按当前可见范围 +和屏幕像素自动保峰降采样,而 tooltip、最近点查询和标注仍使用完整原始数据。 ## 在线示例 最新稳定版 Demo: -## 开始使用 +## 特性 + +- Vue 3 Composition API + TypeScript,支持按需导入 `WaveformChart` +- 采样值、显式坐标点和多系列数据模型 +- `independent`、`separated`、`compact` 三种布局模式 +- 曲线、阶梯线、点符号和对称/非对称误差棒 +- 缩放过程事件、缩放结束按可视区间加载和视口重置 +- 多系列图例、受控显隐、网格分页和最多四根 Y 轴 +- 受控标注、右键编辑、拖拽避让和自定义颜色 +- 标题、图框、坐标轴、时间单位和降采样参数可配置 + +## 安装 ```bash pnpm install pnpm dev ``` -开发环境要求 Node.js 22、pnpm,以及支持 Vue 3 的宿主项目。组件库会将 Vue、D3、 -Ant Design Vue 和 vue3-colorpicker 作为 peer dependency;直接安装到业务项目时请一并 +组件库将 Vue、D3、Ant Design Vue 和 vue3-colorpicker 作为 peer dependency;直接安装到业务项目时请一并 安装这些依赖: ```bash pnpm add waveform-analysis vue d3 ant-design-vue vue3-colorpicker ``` -## 常用命令 - -```bash -pnpm typecheck -pnpm lint -pnpm test -pnpm test:coverage -pnpm build -``` - -`pnpm build` 同时生成 `dist` 组件库产物和 `dist-demo` 演示应用。发布稳定版后,在线示例 -会自动更新;预发布版本不会覆盖在线示例。正式公开入口为 -`src/index.ts`;发布后使用包入口: - -```ts -import { WaveformChart, type WaveformData } from 'waveform-analysis' -import 'waveform-analysis/style.css' -``` - -Vue、D3、Ant Design Vue 和 vue3-colorpicker 是 peer dependencies,需要由使用方安装。 -`WaveformChart` 支持采样值与采样率,也支持显式的 `{ x, y }[]` 点数组。 - ## 发布 -发布由推送版本 tag 触发。先将 `package.json` 的 `version` 更新为目标版本并提交,再创建同版本 tag: +发布由推送版本 tag 触发。`package.json` 的 `version` 必须与 tag 去掉 `v` 后完全一致。 +稳定版使用 `vX.Y.Z`,预发布版使用 `vX.Y.Z-rc.1`;稳定版发布为 npm `latest`,预发布版发布为 +`next`。流水线会创建 Gitea Release,并上传包文件与 SHA-256 校验文件。 -```bash -git tag -a v0.1.7 -m "Release v0.1.7" -git push origin main --follow-tags +## 最小示例 + +```vue + + + + + ``` -支持稳定版 `vX.Y.Z` 与预发布版 `vX.Y.Z-rc.1`。tag 去掉 `v` 后必须与 `package.json` 的 -`version` 完全一致。稳定版发布为 npm `latest` 并更新服务器下载目录的 latest 链接;预发布版发布为 -npm `next`,不会覆盖稳定版 latest。流水线会创建 Gitea Release,并上传 `.tgz` 与 SHA-256 校验文件。 +父容器需要有明确高度;未指定 `width` 或 `height` 时,组件会填充父容器,并保持最小高度 +`180px`。`WaveformChart` 的正式入口为 `src/index.ts`,样式入口为 `waveform-analysis/style.css`。 -仓库 Actions 需要配置 `NPM_PUBLISH_TOKEN`(npm 包发布权限)和 `RELEASE_TOKEN`(仓库 Release -写入权限)两个 Secret。 +## API 速查 + +### Props + +| Prop | 类型 | 默认值 | 说明 | +| --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | --------------------------------- | +| `data` | `WaveformData` | 必填 | 波形数据 | +| `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 | +| `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 | +| `timeUnit` | `'s' \| 'ms'` | `'ms'` | 坐标轴和 tooltip 展示单位 | +| `width` / `height` | `number` | 自适应 | 组件总尺寸,单位为 CSS 像素 | +| `zoomable` / `showTooltip` | `boolean` | `true` / `true` | 缩放和 tooltip 开关 | +| `minZoomSpan` | `number` | 未设置 | 最小缩放跨度,使用原始 X 数据单位 | +| `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围 | +| `initialXDomains` | `Record` | 未设置 | 按 track/series ID 配置初始范围 | +| `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 | +| `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 | +| `title` / `legend` / `frameStyle` | 对应公开类型 | 未设置 | 标题、图例和图框样式 | +| `annotations` | `WaveformAnnotation[]` | `[]` | 受控标注数据 | +| `hiddenSeriesIds` | `string[]` | 未设置 | 受控隐藏系列 ID | +| `defaultHiddenSeriesIds` | `string[]` | `[]` | 非受控模式的初始隐藏系列 | + +所有公开类型均可从包入口导入,例如 `WaveformData`、`WaveformSeries`、 +`WaveformAnnotation`、`WaveformRenderingOptions` 和 `WaveformGridOptions`。 ### 数据结构 @@ -114,21 +152,42 @@ import { WaveformChart } from './index' ### 缩放后按可视区间加载数据 -组件会在一次缩放手势结束后触发 `zoom-end`,调用方可以使用端点请求后端,再通过 `data` -传回新数据。共享 X 轴模式的 payload 为 `{ start, end }`;独立分图模式还会包含 -`trackIndex` 和稳定的 `seriesIds`。 +组件支持 Plotly 风格的矩形框选缩放:在 zoom 模式下按住鼠标左键拖拽,松开后同时缩放 +X/Y 轴;按住空格键拖拽可平移当前视口。鼠标滚轮仍可放大,双击恢复完整视口。 +组件会在滚轮或框选缩放结束后触发 `zoom-end`,调用方可以使用端点请求后端,再通过 +`data` 传回新数据。独立分图模式还会包含 `trackIndex` 和稳定的 `seriesIds`。 ```vue - + ``` -`zoom-change` 仍会在缩放过程中持续触发,适合更新外部状态;后端请求应使用 -`zoom-end` 或在 `zoom-change` 上自行防抖。标注数据应由父组件独立持有,替换波形数据时 +`zoom-change` 会在滚轮、框选和平移过程中触发,适合更新外部状态;后端请求应使用 +`zoom-end`,或在 `zoom-change` 上自行防抖。标注数据应由父组件独立持有,替换波形数据时 不要清空标注,组件会根据当前数据域自动隐藏或恢复对应标注。 +`zoom-end.gesture` 用于区分 `wheel` 和 `box`。单轨道 payload 使用 `yStart/yEnd`;共享 +X 轴且包含多个轨道时使用按稳定 track ID 索引的 `yRanges`。平移不会触发 `zoom-end`, +因此不会自动发起新的区间加载请求。 + 调用方应处理加载失败的情况(网络错误、超时等),并保持旧数据或显示加载状态。生产环境建议使用 `AbortController` 取消过时的请求。 +`initialXDomain` 固定首次完整数据的 X 轴缩放边界,不要将它改成后端返回的当前窗口;独立图框有不同时间范围时,可通过 +`initialXDomains` 按 track ID 或 series ID 分别配置。`minZoomSpan` 使用原始 X 数据单位, +可防止每次区间数据回填后重新累计放大。双击图框会 +重置组件内部缩放并触发 `zoom-reset`;调用方应在事件中取消区间请求并恢复首次完整数据。 +外部重置按钮也可以通过模板引用调用组件公开的 `resetViewport()` 方法,然后执行相同的数据恢复逻辑。 + +独立坐标模式下,回填响应应只替换 `seriesIds` 对应的系列,并调用 +`resetViewport(trackIndex)`;其他图框的数据和缩放状态应保持不变。 + 多通道数据应为每个 `WaveformSeries` 提供稳定的 `id`。内部时间坐标始终使用秒, `timeUnit` 只控制坐标轴和 tooltip 的显示单位。 @@ -395,14 +454,15 @@ X、Y 轴会根据各自完整显示域选择格式:最大绝对值在 `[0.01, 组件提供以下事件,名称与 Vue 模板写法一致: -| 事件 | 说明 | -| --------------------------------------------------------------- | ---------------------------------------------------------- | -| `point-hover` | 当前最近点变化时触发,离开图表时传入 `null` | -| `zoom-change` | 缩放过程中触发,参数为 `[start, end]` | -| `zoom-end` | 缩放结束后触发;独立分图模式附带 `trackIndex`、`seriesIds` | -| `page-change` | 分页变化,参数为当前页和总页数 | -| `series-visibility-change` | 图例切换曲线显隐时触发 | -| `annotation-create` / `annotation-update` / `annotation-delete` | 标注新增、更新或删除 | +| 事件 | 说明 | +| --------------------------------------------------------------- | -------------------------------------------------------------- | +| `point-hover` | 当前最近点变化时触发,离开图表时传入 `null` | +| `zoom-change` | 缩放过程中触发,参数为 `[start, end]` | +| `zoom-end` | 滚轮放大结束后触发;独立分图模式附带 `trackIndex`、`seriesIds` | +| `zoom-reset` | 双击重置视口时触发,调用方应恢复首次完整数据 | +| `page-change` | 分页变化,参数为当前页和总页数 | +| `series-visibility-change` | 图例切换曲线显隐时触发 | +| `annotation-create` / `annotation-update` / `annotation-delete` | 标注新增、更新或删除 | `annotations`、`annotations-visible`、`interaction-mode` 和 `hidden-series-ids` 均支持 `v-model`;业务层应负责将标注和显隐状态持久化。 @@ -414,5 +474,37 @@ X、Y 轴会根据各自完整显示域选择格式:最大绝对值在 `[0.01, - `src/components/{core,data,rendering,interaction,annotation}`:数据、布局、渲染和交互模块 - `src/App.vue`:可交互 demo,`src/data` 中提供示例波形数据 -构建后,`dist/` 是可发布的组件库,`dist-demo/` 是 demo 静态产物;两者均为生成目录, -不要手工编辑。 +## 本地开发 + +开发环境要求 Node.js 22 和 pnpm: + +```bash +pnpm install +pnpm dev +``` + +常用质量检查和构建命令: + +```bash +pnpm typecheck +pnpm lint +pnpm test +pnpm test:coverage +pnpm build +``` + +`pnpm build` 同时生成 `dist/` 组件库产物和 `dist-demo/` 演示应用。正式公开入口为 +`src/index.ts`,样式入口为 `src/styles.css`;`dist/` 和 `dist-demo/` 均为生成目录,不要手工编辑。 + +## 发布流程 + +发布由推送版本 tag 触发。先将 `package.json` 的 `version` 更新为目标版本并提交,再创建同版本 tag: + +```bash +git tag -a v0.1.7 -m "Release v0.1.7" +git push origin main --follow-tags +``` + +支持稳定版 `vX.Y.Z` 与预发布版 `vX.Y.Z-rc.1`。tag 去掉 `v` 后必须与 `package.json` 的 +`version` 完全一致。稳定版发布为 npm `latest`,预发布版发布为 npm `next`。流水线会创建 Gitea +Release,并上传 `.tgz` 与 SHA-256 校验文件。 diff --git a/package.json b/package.json index 7d7b815..aa9cab5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "waveform-analysis", - "version": "0.1.11", + "version": "0.1.12", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/types/index.d.ts", diff --git a/src/App.test.ts b/src/App.test.ts index e6707a2..84052ed 100644 --- a/src/App.test.ts +++ b/src/App.test.ts @@ -1,11 +1,36 @@ import { flushPromises, mount } from '@vue/test-utils' import { InputNumber, Select } from 'ant-design-vue' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { ColorPicker } from 'vue3-colorpicker' import App from './App.vue' +import { WaveformChart, type WaveformData } from './components' describe('App workspace layout', { timeout: 20_000 }, () => { + it('restores full data and invalidates a pending zoom request', async () => { + vi.useFakeTimers() + const wrapper = mount(App) + try { + await flushPromises() + const chart = wrapper.getComponent(WaveformChart) + const pointCount = (data: WaveformData) => + data.kind === 'series' && data.series[0]?.data.kind === 'points' + ? data.series[0].data.points.length + : 0 + const initialPointCount = pointCount(chart.props('data') as WaveformData) + + chart.vm.$emit('zoom-end', { start: 0, end: 0.001 }) + await wrapper.get('[aria-label="重置波形视图"]').trigger('click') + await vi.advanceTimersByTimeAsync(100) + await flushPromises() + + expect(pointCount(chart.props('data') as WaveformData)).toBe(initialPointCount) + } finally { + wrapper.unmount() + vi.useRealTimers() + } + }) + it('places controls in the sidebar beside the chart', async () => { const wrapper = mount(App) await flushPromises() diff --git a/src/App.vue b/src/App.vue index b836855..f4cce8a 100644 --- a/src/App.vue +++ b/src/App.vue @@ -20,6 +20,7 @@ import { } from './components' import chartWaveformsJson from './data/chartWaveforms.json' import demoWaveformsJson from './data/demoWaveforms.json' +import { normalizeWaveformSeries } from './core' interface WaveformSourcePoint { x: number @@ -187,7 +188,25 @@ const fullChartData: WaveformData = { kind: 'series', series: [...frameOneSeries, ...basicCurveDemoSeries, ...stepDemoSeries, ...remainingSeries], } +const initialXValues = normalizeWaveformSeries(fullChartData).flatMap((series) => + series.points.map((point) => point.x), +) +const [initialXMinimum, initialXMaximum] = initialXValues.reduce<[number, number]>( + ([minimum, maximum], value) => [Math.min(minimum, value), Math.max(maximum, value)], + [Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY], +) +const initialXSpan = initialXMaximum - initialXMinimum +const minZoomSpan = + Number.isFinite(initialXSpan) && initialXSpan > 0 ? initialXSpan / 40 : undefined +const initialXDomainValue: [number, number] | undefined = + Number.isFinite(initialXMinimum) && Number.isFinite(initialXMaximum) + ? [initialXMinimum, initialXMaximum] + : undefined +// Keep the full source domain stable while viewport data windows are replaced. +const initialXDomain = ref<[number, number] | undefined>(initialXDomainValue) const chartData = ref(fullChartData) +const waveformChartRef = ref<{ resetViewport: (trackIndex?: number) => void }>() + let zoomRequestSequence = 0 function filterWaveformData(data: WaveformData, start: number, end: number): WaveformData { @@ -215,6 +234,22 @@ function filterWaveformData(data: WaveformData, start: number, end: number): Wav } } +function mergeIndependentWindow( + currentData: WaveformData, + responseData: WaveformData, + seriesIds: string[], +): WaveformData { + if (currentData.kind !== 'series' || responseData.kind !== 'series') return responseData + const responseById = new Map(responseData.series.map((series) => [series.id, series])) + const changedIds = new Set(seriesIds) + return { + kind: 'series', + series: currentData.series.map((series) => + series.id && changedIds.has(series.id) ? (responseById.get(series.id) ?? series) : series, + ), + } +} + async function handleZoomEnd(payload: WaveformZoomEndPayload) { // Demo-only sequence number cancellation. Production code should use AbortController // to cancel in-flight requests when a newer zoom gesture arrives. @@ -224,7 +259,18 @@ async function handleZoomEnd(payload: WaveformZoomEndPayload) { // Demo-only stand-in for the backend response. Production code should replace this // with a request using payload.start/payload.end and the optional channel metadata. - chartData.value = filterWaveformData(fullChartData, payload.start, payload.end) + const responseData = filterWaveformData(fullChartData, payload.start, payload.end) + chartData.value = + payload.trackIndex !== undefined && payload.seriesIds?.length + ? mergeIndependentWindow(chartData.value, responseData, payload.seriesIds) + : responseData +} + +function resetWaveformViewport() { + zoomRequestSequence += 1 + chartData.value = fullChartData + initialXDomain.value = initialXDomainValue + waveformChartRef.value?.resetViewport() } const titleOptions = computed(() => ({ visible: titleVisible.value, @@ -304,6 +350,11 @@ onBeforeUnmount(() => window.removeEventListener('keydown', handleWindowKeydown) +
+

视图

+ +
+

叠加方式

window.removeEventListener('keydown', handleWindowKeydown)
window.removeEventListener('keydown', handleWindowKeydown) v-model:interaction-mode="interactionMode" v-model:hidden-series-ids="hiddenSeriesIds" @zoom-end="handleZoomEnd" + @zoom-reset="resetWaveformViewport" />
diff --git a/src/components/WaveformChart.test.ts b/src/components/WaveformChart.test.ts index 9df85b6..c4a98cc 100644 --- a/src/components/WaveformChart.test.ts +++ b/src/components/WaveformChart.test.ts @@ -1261,6 +1261,75 @@ describe('WaveformChart', () => { } }) + it('uses each track x domain in independent mode instead of the shared initial domain', async () => { + const wrapper = await mountSizedChart( + { + kind: 'series', + series: [ + { + id: 'narrow-track', + name: '窄范围', + data: { + kind: 'points', + points: [ + { x: 0, y: 0 }, + { x: 0.006, y: 1 }, + ], + }, + }, + { + id: 'wide-track', + name: '宽范围', + data: { + kind: 'points', + points: [ + { x: -8, y: 0 }, + { x: 5, y: 1 }, + ], + }, + }, + ], + }, + { + displayMode: 'independent', + grid: { rowCount: 1, columnCount: 2 }, + initialXDomain: [-8, 5], + }, + ) + + const tracks = wrapper.findAll('.waveform-chart__track') + expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('6.00') + expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5.00') + }) + + it('uses an explicit initial x domain override for an independent track', async () => { + const wrapper = await mountSizedChart( + { + kind: 'series', + series: [ + { + id: 'narrow-track', + name: '窄范围', + data: { + kind: 'points', + points: [ + { x: 0, y: 0 }, + { x: 0.006, y: 1 }, + ], + }, + }, + ], + }, + { + displayMode: 'independent', + initialXDomain: [-8, 5], + initialXDomains: { 'narrow-track': [0, 1] }, + }, + ) + + expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00') + }) + it('keeps annotations bound to their channel while paging', async () => { const wrapper = await mountSizedChart(gridSeries(3), { grid: { rowCount: 1, columnCount: 1 }, @@ -1838,6 +1907,9 @@ describe('WaveformChart', () => { ).toBeUndefined() expect(wrapper.get('.waveform-chart__plot-background').attributes('fill')).toBe('transparent') expect(wrapper.get('.waveform-chart__watermark').text()).toBe('12') + expect(getComputedStyle(wrapper.get('.waveform-chart__watermark').element).userSelect).toBe( + 'none', + ) expect(wrapper.get('.waveform-chart__line').attributes('stroke')).toBe('#0960bd') }) @@ -2122,11 +2194,207 @@ describe('WaveformChart', () => { expect(payload.start).toBeGreaterThanOrEqual(0) expect(payload.end).toBeLessThanOrEqual(2) expect(payload.start).toBeLessThan(payload.end) + expect(payload.end - payload.start).toBeCloseTo(2 / 40) } finally { vi.useRealTimers() } }) + it('zooms only the x axis and identifies the box gesture', async () => { + const wrapper = await mountSizedChart({ + kind: 'points', + points: [ + { x: 0, y: 0 }, + { x: 1, y: 10 }, + { x: 2, y: 20 }, + ], + }) + const overlay = wrapper.get('.waveform-chart__overlay--independent') + const width = Number(overlay.attributes('width')) + const height = Number(overlay.attributes('height')) + Object.defineProperty(overlay.element, 'getBoundingClientRect', { + value: () => ({ left: 0, top: 0, width, height }), + }) + + const dispatchPointer = (type: string, clientX: number, clientY: number) => { + const event = new MouseEvent(type, { button: 0, clientX, clientY, bubbles: true }) + Object.defineProperty(event, 'pointerId', { value: 7 }) + overlay.element.dispatchEvent(event) + } + dispatchPointer('pointerdown', width * 0.25, height / 2) + dispatchPointer('pointermove', width * 0.75, height / 2 + 2) + await flushPromises() + expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(true) + dispatchPointer('pointerup', width * 0.75, height * 0.75) + await flushPromises() + + const payload = wrapper.emitted('zoom-end')?.at(-1)?.[0] as + | { + start: number + end: number + yStart?: number + yEnd?: number + trackIndex: number + seriesIds: string[] + gesture: string + } + | undefined + expect(payload).toMatchObject({ trackIndex: 0, seriesIds: ['series-0'], gesture: 'box' }) + expect(payload?.end).toBeGreaterThan(payload?.start ?? Number.POSITIVE_INFINITY) + expect(payload?.yStart).toBeDefined() + expect(payload?.yEnd).toBeDefined() + expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(false) + }) + + it('limits box zoom to the configured minimum x span', async () => { + const wrapper = await mountSizedChart( + { + kind: 'points', + points: [ + { x: 0, y: 0 }, + { x: 1, y: 10 }, + { x: 2, y: 20 }, + ], + }, + { minZoomSpan: 0.25 }, + ) + const overlay = wrapper.get('.waveform-chart__overlay--independent') + const width = Number(overlay.attributes('width')) + const height = Number(overlay.attributes('height')) + Object.defineProperty(overlay.element, 'getBoundingClientRect', { + value: () => ({ left: 0, top: 0, width, height }), + }) + const dispatchPointer = (type: string, clientX: number, clientY: number) => { + const event = new MouseEvent(type, { button: 0, clientX, clientY, bubbles: true }) + Object.defineProperty(event, 'pointerId', { value: 8 }) + overlay.element.dispatchEvent(event) + } + dispatchPointer('pointerdown', width / 2, height / 2) + dispatchPointer('pointermove', width / 2 + 8, height / 2 + 8) + dispatchPointer('pointerup', width / 2 + 8, height / 2 + 8) + await flushPromises() + + const payload = wrapper.emitted('zoom-end')?.at(-1)?.[0] as { start: number; end: number } + expect(payload.end - payload.start).toBeGreaterThanOrEqual(0.25 - 1e-8) + }) + + it('does not zoom a track when its visible sample count is below the configured minimum', async () => { + const wrapper = await mountSizedChart( + { + kind: 'points', + points: [ + { x: 0, y: 0 }, + { x: 1, y: 10 }, + { x: 2, y: 20 }, + ], + }, + { minVisiblePoints: 10 }, + ) + const overlay = wrapper.get('.waveform-chart__overlay--independent') + const width = Number(overlay.attributes('width')) + Object.defineProperty(overlay.element, 'getBoundingClientRect', { + value: () => ({ left: 0, top: 0, width, height: 290 }), + }) + overlay.element.dispatchEvent( + new WheelEvent('wheel', { + deltaY: -1000, + clientX: width / 2, + clientY: 145, + bubbles: true, + cancelable: true, + }), + ) + flushAnimationFrames() + await flushPromises() + + expect(wrapper.emitted('zoom-change')).toBeUndefined() + expect(wrapper.emitted('zoom-end')).toBeUndefined() + }) + + it('ignores shared viewport dragging', async () => { + const wrapper = await mountSizedChart({ + kind: 'points', + points: [ + { x: 0, y: 0 }, + { x: 2, y: 1 }, + ], + }) + const overlay = wrapper.get('.waveform-chart__overlay') + const width = Number(overlay.attributes('width')) + const createDragEvent = (type: string, init: MouseEventInit) => { + const event = new MouseEvent(type, init) + Object.defineProperty(event, 'view', { value: window }) + return event + } + Object.defineProperty(overlay.element, 'getBoundingClientRect', { + value: () => ({ left: 0, top: 0, width, height: 290 }), + }) + overlay.element.dispatchEvent( + createDragEvent('mousedown', { + button: 0, + clientX: width / 2, + clientY: 145, + bubbles: true, + cancelable: true, + }), + ) + window.dispatchEvent( + new MouseEvent('mousemove', { + clientX: width / 2 + 80, + clientY: 145, + bubbles: true, + }), + ) + window.dispatchEvent(createDragEvent('mouseup', { bubbles: true })) + flushAnimationFrames() + await flushPromises() + + expect(wrapper.emitted('zoom-change')).toBeUndefined() + expect(wrapper.emitted('zoom-end')).toBeUndefined() + expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0.00') + expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00') + }) + + it('ignores independent viewport dragging', async () => { + const wrapper = await mountSizedChart(gridSeries(2), { + displayMode: 'independent', + grid: { rowCount: 1, columnCount: 2 }, + }) + const overlay = wrapper.findAll('.waveform-chart__overlay--independent')[0] + const width = Number(overlay.attributes('width')) + const createDragEvent = (type: string, init: MouseEventInit) => { + const event = new MouseEvent(type, init) + Object.defineProperty(event, 'view', { value: window }) + return event + } + Object.defineProperty(overlay.element, 'getBoundingClientRect', { + value: () => ({ left: 0, top: 0, width, height: 260 }), + }) + + overlay.element.dispatchEvent( + createDragEvent('mousedown', { + button: 0, + clientX: width / 2, + clientY: 130, + bubbles: true, + cancelable: true, + }), + ) + window.dispatchEvent( + new MouseEvent('mousemove', { + clientX: width / 2 + 60, + clientY: 130, + bubbles: true, + }), + ) + window.dispatchEvent(createDragEvent('mouseup', { bubbles: true })) + flushAnimationFrames() + await flushPromises() + + expect(wrapper.emitted('zoom-change')).toBeUndefined() + expect(wrapper.emitted('zoom-end')).toBeUndefined() + }) + it('includes track and series IDs in independent zoom-end payloads', async () => { vi.useFakeTimers() try { @@ -2161,6 +2429,222 @@ describe('WaveformChart', () => { } }) + it('keeps the global minimum zoom span after replacing the data window', async () => { + const wrapper = await mountSizedChart( + { + kind: 'points', + points: [ + { x: 0, y: 0 }, + { x: 100, y: 1 }, + ], + }, + { minZoomSpan: 10 }, + ) + const overlay = wrapper.get('.waveform-chart__overlay') + const width = Number(overlay.attributes('width')) + Object.defineProperty(overlay.element, 'getBoundingClientRect', { + value: () => ({ left: 0, top: 0, width, height: 290 }), + }) + const zoomAtCenter = async () => { + overlay.element.dispatchEvent( + new WheelEvent('wheel', { + deltaY: -4000, + clientX: width / 2, + clientY: 145, + bubbles: true, + cancelable: true, + }), + ) + flushAnimationFrames() + await flushPromises() + } + + await zoomAtCenter() + const firstDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number] + expect(firstDomain[1] - firstDomain[0]).toBeCloseTo(10) + + await wrapper.setProps({ + data: { + kind: 'points', + points: [ + { x: firstDomain[0], y: 0 }, + { x: firstDomain[1], y: 1 }, + ], + }, + }) + await flushPromises() + await zoomAtCenter() + + const secondDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number] + expect(secondDomain[1] - secondDomain[0]).toBeCloseTo(10) + }) + + it('keeps one global domain while loading narrower and wider windows', async () => { + const wrapper = await mountSizedChart( + { + kind: 'points', + points: [ + { x: 0, y: 0 }, + { x: 100, y: 1 }, + ], + }, + { displayMode: 'separated', initialXDomain: [0, 100] }, + ) + const overlay = wrapper.get('.waveform-chart__overlay') + const width = Number(overlay.attributes('width')) + Object.defineProperty(overlay.element, 'getBoundingClientRect', { + value: () => ({ left: 0, top: 0, width, height: 290 }), + }) + const dispatchWheel = async (deltaY: number) => { + overlay.element.dispatchEvent( + new WheelEvent('wheel', { + deltaY, + clientX: width / 2, + clientY: 145, + bubbles: true, + cancelable: true, + }), + ) + flushAnimationFrames() + await flushPromises() + } + + await dispatchWheel(-4000) + const zoomedDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number] + expect(zoomedDomain[1] - zoomedDomain[0]).toBeCloseTo(2.5) + + await wrapper.setProps({ + data: { + kind: 'points', + points: [ + { x: 48, y: 0 }, + { x: 52, y: 1 }, + ], + }, + }) + await flushPromises() + const preservedDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number] + expect(preservedDomain[1] - preservedDomain[0]).toBeCloseTo(2.5) + + const eventCount = wrapper.emitted('zoom-change')?.length ?? 0 + await dispatchWheel(4000) + const currentDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number] + expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount) + expect(currentDomain[1] - currentDomain[0]).toBeCloseTo(2.5) + }) + + it('resets a shared viewport on double-click and emits zoom-reset', async () => { + const wrapper = await mountSizedChart({ + kind: 'points', + points: [ + { x: 0, y: 0 }, + { x: 2, y: 1 }, + ], + }) + const overlay = wrapper.get('.waveform-chart__overlay') + const width = Number(overlay.attributes('width')) + Object.defineProperty(overlay.element, 'getBoundingClientRect', { + value: () => ({ left: 0, top: 0, width, height: 290 }), + }) + overlay.element.dispatchEvent( + new WheelEvent('wheel', { + deltaY: -4000, + clientX: width / 2, + clientY: 145, + bubbles: true, + cancelable: true, + }), + ) + flushAnimationFrames() + await flushPromises() + expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe('0.00') + + overlay.element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true })) + await flushPromises() + + expect(wrapper.emitted('zoom-reset')).toHaveLength(1) + expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0.00') + expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00') + }) + + it('exposes resetViewport for independent tracks', async () => { + const wrapper = await mountSizedChart(gridSeries(2), { + displayMode: 'independent', + grid: { rowCount: 1, columnCount: 2 }, + }) + const overlay = wrapper.findAll('.waveform-chart__overlay--independent')[0] + const width = Number(overlay.attributes('width')) + Object.defineProperty(overlay.element, 'getBoundingClientRect', { + value: () => ({ left: 0, top: 0, width, height: 260 }), + }) + overlay.element.dispatchEvent( + new WheelEvent('wheel', { + deltaY: -4000, + clientX: width / 2, + clientY: 130, + bubbles: true, + cancelable: true, + }), + ) + flushAnimationFrames() + await flushPromises() + expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).not.toBe('0.00') + + const chart = wrapper.vm as unknown as { resetViewport: () => void } + chart.resetViewport() + await flushPromises() + + expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0.00') + expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1.00') + }) + + it('keeps other independent tracks unchanged when one data window is replaced', async () => { + const wrapper = await mountSizedChart(gridSeries(2), { + displayMode: 'independent', + grid: { rowCount: 1, columnCount: 2 }, + }) + const originalEndpoints = wrapper + .findAll('.waveform-chart__axis-endpoint--end') + .map((endpoint) => endpoint.text()) + + await wrapper.setProps({ + data: { + kind: 'series', + series: [ + { + id: 'channel-0', + name: '通道 1', + data: { + kind: 'points', + points: [ + { x: 0.25, y: 0 }, + { x: 0.75, y: 1 }, + ], + }, + }, + { + id: 'channel-1', + name: '通道 2', + data: { + kind: 'points', + points: [ + { x: 0, y: 1 }, + { x: 1, y: 2 }, + ], + }, + }, + ], + }, + }) + await flushPromises() + + const nextEndpoints = wrapper + .findAll('.waveform-chart__axis-endpoint--end') + .map((endpoint) => endpoint.text()) + expect(nextEndpoints[0]).not.toBe(originalEndpoints[0]) + expect(nextEndpoints[1]).toBe(originalEndpoints[1]) + }) + it('rebuilds cached domains only when the data reference changes', async () => { const firstData: WaveformData = { kind: 'points', diff --git a/src/components/WaveformChart.vue b/src/components/WaveformChart.vue index bd67efe..cd336d2 100644 --- a/src/components/WaveformChart.vue +++ b/src/components/WaveformChart.vue @@ -92,6 +92,10 @@ const props = withDefaults( lineColor?: string showTooltip?: boolean zoomable?: boolean + minZoomSpan?: number + minVisiblePoints?: number + initialXDomain?: [number, number] + initialXDomains?: Record timeUnit?: 's' | 'ms' frameNumber?: string | number frameStyle?: WaveformFrameStyle @@ -113,6 +117,7 @@ const props = withDefaults( lineColor: '#0960bd', showTooltip: true, zoomable: true, + minVisiblePoints: 0, timeUnit: 'ms', frameNumber: undefined, annotations: () => [], @@ -130,6 +135,7 @@ const emit = defineEmits<{ 'point-hover': [point: WaveformPoint | null] 'zoom-change': [domain: [number, number]] 'zoom-end': [payload: WaveformZoomEndPayload] + 'zoom-reset': [] 'update:annotations': [annotations: WaveformAnnotation[]] 'update:annotations-visible': [visible: boolean] 'update:interaction-mode': [mode: WaveformInteractionMode] @@ -159,6 +165,8 @@ const measuredTitleWidth = ref(0) const measuredTitleHeight = ref(0) const sharedTransform = shallowRef(zoomIdentity) const independentTransforms = shallowRef([]) +const sharedYDomains = ref>({}) +const independentYDomains = ref>({}) const hoveredSeriesPoints = ref([]) const hoveredTrackIndex = ref(null) const hoverPosition = ref({ x: 0, y: 0 }) @@ -174,12 +182,53 @@ const editorSeriesOptions = ref([]) let generatedAnnotationId = 0 let synchronizingZoomTransform = false let pendingSharedZoomTransform: ZoomTransform | null = null +type ZoomGestureKind = 'wheel' +let pendingSharedZoomGesture: ZoomGestureKind | null = null +let lastSharedZoomGesture: ZoomGestureKind | null = null const pendingIndependentZoomTransforms = new Map() +const pendingIndependentZoomGestures = new Map() +const lastIndependentZoomGestures = new Map() const lastZoomedTrackIndexes = new Set() const zoomThrottle = useAnimationFrameThrottle() const hoverThrottle = useAnimationFrameThrottle() const preparedSeries = usePreparedWaveformSeries(() => props.data, handleDataReferenceChange) +interface SelectionState { + trackIndex: number + independent: boolean + overlay: SVGRectElement + startX: number + startY: number + currentX: number + currentY: number + pointerId: number + mode: 'box' | 'pan' + xDomain: [number, number] + yDomains: Record +} + +const selection = ref(null) +const spacePressed = ref(false) +const selectionBox = computed(() => { + const active = selection.value + if (!active) return null + const track = trackLayouts.value.find((item) => item.index === active.trackIndex) + return { + x: Math.min(active.startX, active.currentX) + (active.independent ? (track?.left ?? 0) : 0), + y: Math.min(active.startY, active.currentY) + (active.independent ? (track?.top ?? 0) : 0), + width: Math.abs(active.currentX - active.startX), + height: Math.abs(active.currentY - active.startY), + } +}) + +function handleInteractionKeyDown(event: KeyboardEvent) { + if (event.code === 'Space') spacePressed.value = true +} + +function handleInteractionKeyUp(event: KeyboardEvent) { + if (event.code === 'Space') spacePressed.value = false +} + // 用于传递给 WaveformTooltip 的接口 interface TooltipSeriesPoint { trackIndex: number @@ -475,10 +524,39 @@ const sharedXDomain = computed(() => chartTracks.value.flatMap((track) => (track.visibleSeries.length ? track.xDomain : [])), ), ) +const initialXDomain = computed<[number, number]>(() => { + const domain = props.initialXDomain + if ( + domain && + Number.isFinite(domain[0]) && + Number.isFinite(domain[1]) && + domain[0] !== domain[1] + ) { + return domain[0] < domain[1] ? domain : [domain[1], domain[0]] + } + return sharedXDomain.value +}) +function resolveInitialTrackDomain(track: TrackLayout): [number, number] { + const configuredDomain = + props.initialXDomains?.[track.series.trackId ?? track.series.id] ?? + props.initialXDomains?.[track.series.id] ?? + props.initialXDomain + if ( + configuredDomain && + Number.isFinite(configuredDomain[0]) && + Number.isFinite(configuredDomain[1]) && + configuredDomain[0] !== configuredDomain[1] + ) { + return configuredDomain[0] < configuredDomain[1] + ? configuredDomain + : [configuredDomain[1], configuredDomain[0]] + } + return paddedDomain(track.seriesList.flatMap((series) => series.xDomain)) +} const sharedZoomDomain = computed( () => sharedTransform.value - .rescaleX(scaleLinear(sharedXDomain.value, [0, innerWidth.value])) + .rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value])) .domain() as [number, number], ) @@ -502,6 +580,17 @@ const trackLayouts = computed(() => overlayMode: props.overlayMode, independentTransforms: independentTransforms.value, sharedZoomDomain: sharedZoomDomain.value, + initialXDomain: props.initialXDomain ? initialXDomain.value : undefined, + initialXDomains: props.initialXDomains, + yDomains: + props.displayMode === 'independent' + ? Object.fromEntries( + chartTracks.value.flatMap((track, index) => { + const domain = independentYDomains.value[index] + return domain ? [[track.id, domain]] : [] + }), + ) + : sharedYDomains.value, timeUnit: props.timeUnit, rendering: renderingOptions.value, hideSecondaryLabels: yAxisLayout.value.hideSecondaryLabels, @@ -568,6 +657,7 @@ function handleSharedZoom(event: D3ZoomEvent) { if (synchronizingZoomTransform) return cancelPendingHover() pendingSharedZoomTransform = event.transform + pendingSharedZoomGesture = 'wheel' scheduleZoomCommit() } @@ -575,6 +665,7 @@ function handleIndependentZoom(event: D3ZoomEvent, trac if (synchronizingZoomTransform) return cancelPendingHover() pendingIndependentZoomTransforms.set(trackIndex, event.transform) + pendingIndependentZoomGestures.set(trackIndex, 'wheel') scheduleZoomCommit() } @@ -582,9 +673,11 @@ function commitPendingZoom() { if (pendingSharedZoomTransform) { const transform = pendingSharedZoomTransform pendingSharedZoomTransform = null + lastSharedZoomGesture = pendingSharedZoomGesture + pendingSharedZoomGesture = null sharedTransform.value = transform const domain = transform - .rescaleX(scaleLinear(sharedXDomain.value, [0, innerWidth.value])) + .rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value])) .domain() emit('zoom-change', [domain[0], domain[1]]) } @@ -595,10 +688,15 @@ function commitPendingZoom() { // Clear stale track indexes before recording the new batch lastZoomedTrackIndexes.clear() changedTrackIndexes.forEach((trackIndex) => lastZoomedTrackIndexes.add(trackIndex)) + changedTrackIndexes.forEach((trackIndex) => { + const gesture = pendingIndependentZoomGestures.get(trackIndex) + if (gesture) lastIndependentZoomGestures.set(trackIndex, gesture) + }) pendingIndependentZoomTransforms.forEach((transform, trackIndex) => { nextTransforms[trackIndex] = transform }) pendingIndependentZoomTransforms.clear() + pendingIndependentZoomGestures.clear() independentTransforms.value = nextTransforms changedTrackIndexes.forEach((trackIndex) => { const track = trackLayouts.value.find((item) => item.index === trackIndex) @@ -622,29 +720,58 @@ function flushPendingZoom() { function emitZoomEnd() { if (props.displayMode === 'independent') { lastZoomedTrackIndexes.forEach((trackIndex) => { + const gesture = lastIndependentZoomGestures.get(trackIndex) + if (gesture !== 'wheel') return const track = trackLayouts.value.find((item) => item.index === trackIndex) if (!track) return const domain = track.xScale.domain() as [number, number] emit('zoom-end', { start: domain[0], end: domain[1], + yStart: track.yScale.domain()[0], + yEnd: track.yScale.domain()[1], trackIndex, seriesIds: track.seriesList.map((series) => series.id), + gesture: 'wheel', }) }) lastZoomedTrackIndexes.clear() + lastIndependentZoomGestures.clear() return } + if (lastSharedZoomGesture !== 'wheel') { + lastSharedZoomGesture = null + return + } const domain = sharedZoomDomain.value - emit('zoom-end', { start: domain[0], end: domain[1] }) + const visibleTracks = trackLayouts.value.filter((track) => track.hasVisibleSeries) + const payload: WaveformZoomEndPayload = { start: domain[0], end: domain[1], gesture: 'wheel' } + if (visibleTracks.length === 1) { + const yDomain = visibleTracks[0]?.yScale.domain() + payload.yStart = yDomain?.[0] + payload.yEnd = yDomain?.[1] + } else { + payload.yRanges = Object.fromEntries( + visibleTracks.map((track) => [ + track.series.trackId ?? track.series.id, + track.yScale.domain() as [number, number], + ]), + ) + } + emit('zoom-end', payload) + lastSharedZoomGesture = null } function cancelPendingZoom() { // Clear all pending zoom state to prevent stale emissions pendingSharedZoomTransform = null + pendingSharedZoomGesture = null + lastSharedZoomGesture = null pendingIndependentZoomTransforms.clear() + pendingIndependentZoomGestures.clear() lastZoomedTrackIndexes.clear() + lastIndependentZoomGestures.clear() zoomThrottle.cancel() } @@ -660,6 +787,35 @@ function clearZoomBindings() { zoomBehaviors.clear() } +function resolveMaximumZoomScale(domain: [number, number]): number { + const minZoomSpan = props.minZoomSpan + if (!Number.isFinite(minZoomSpan) || (minZoomSpan ?? 0) <= 0) return 40 + const domainSpan = Math.abs(domain[1] - domain[0]) + if (!Number.isFinite(domainSpan) || domainSpan <= 0) return 1 + return Math.min(40, Math.max(1, domainSpan / (minZoomSpan ?? domainSpan))) +} + +function visiblePointCount(track: TrackLayout): number { + const [start, end] = track.xScale.domain() + const xValues = new Set() + track.seriesList.forEach((series) => { + series.points.forEach((point) => { + if (point.x >= start && point.x <= end) xValues.add(point.x) + }) + }) + return xValues.size +} + +function canZoomTrack(track: TrackLayout): boolean { + const minimum = Number(props.minVisiblePoints) + return !Number.isFinite(minimum) || minimum <= 0 || visiblePointCount(track) >= minimum +} + +function canZoomSharedTracks(): boolean { + const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries) + return tracks.length > 0 && tracks.every(canZoomTrack) +} + function configureZoom() { clearZoomBindings() if (!props.zoomable || !isZoomMode.value || !hasChartArea.value || !trackLayouts.value.length) @@ -671,8 +827,13 @@ function configureZoom() { `[data-independent-overlay-index="${track.index}"]`, ) if (!overlay) return + const dataDomain = resolveInitialTrackDomain(track) const behavior = zoom() - .scaleExtent([1, 40]) + .filter( + (event) => + event.type === 'wheel' && (event as WheelEvent).deltaY < 0 && canZoomTrack(track), + ) + .scaleExtent([1, resolveMaximumZoomScale(dataDomain)]) .extent([ [0, 0], [track.width, track.height], @@ -688,6 +849,7 @@ function configureZoom() { try { select(overlay) .call(behavior) + .on('dblclick.zoom', null) .call(behavior.transform, independentTransforms.value[track.index] ?? zoomIdentity) } finally { synchronizingZoomTransform = false @@ -698,7 +860,11 @@ function configureZoom() { if (!sharedOverlayElement.value) return const behavior = zoom() - .scaleExtent([1, 40]) + .filter( + (event) => + event.type === 'wheel' && (event as WheelEvent).deltaY < 0 && canZoomSharedTracks(), + ) + .scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value)]) .extent([ [0, 0], [innerWidth.value, innerHeight.value], @@ -714,7 +880,10 @@ function configureZoom() { if (overlay) { synchronizingZoomTransform = true try { - select(overlay).call(behavior).call(behavior.transform, sharedTransform.value) + select(overlay) + .call(behavior) + .on('dblclick.zoom', null) + .call(behavior.transform, sharedTransform.value) } finally { synchronizingZoomTransform = false } @@ -1100,6 +1269,10 @@ function confirmAnnotation(annotation: WaveformAnnotation) { } function handleIndependentPointerMove(event: PointerEvent, trackIndex: number) { + if (selection.value?.trackIndex === trackIndex && selection.value.independent) { + updateViewportDrag(event) + return + } if (consumeHoverSuppression()) return const overlay = event.currentTarget as SVGRectElement | null if (!overlay) return @@ -1126,6 +1299,10 @@ function handleIndependentPointerMove(event: PointerEvent, trackIndex: number) { } function handleSharedPointerMove(event: PointerEvent) { + if (selection.value?.overlay === event.currentTarget) { + updateViewportDrag(event) + return + } if (consumeHoverSuppression()) return if (!sharedOverlayElement.value || !trackLayouts.value.length) return const [pointerX, pointerY] = pointer(event, sharedOverlayElement.value) @@ -1152,15 +1329,260 @@ function handleSharedPointerMove(event: PointerEvent) { }) } -function resetViewport() { +const minimumSelectionSize = 6 + +function transformForDomain( + domain: [number, number], + baseDomain: [number, number], + width: number, +): ZoomTransform { + const baseSpan = baseDomain[1] - baseDomain[0] + const span = domain[1] - domain[0] + if (!Number.isFinite(baseSpan) || !Number.isFinite(span) || baseSpan <= 0 || span <= 0) { + return zoomIdentity + } + const scale = baseSpan / span + const baseScale = scaleLinear(baseDomain, [0, width]) + return zoomIdentity.translate(-scale * baseScale(domain[0]), 0).scale(scale) +} + +function resolveMinimumZoomSpan(boundary: [number, number]): number { + const boundarySpan = Math.abs(boundary[1] - boundary[0]) + if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return 0 + const configured = props.minZoomSpan + if (Number.isFinite(configured) && (configured ?? 0) > 0) { + return Math.min(boundarySpan, configured as number) + } + return boundarySpan / 40 +} + +function constrainZoomDomain( + domain: [number, number], + boundary: [number, number], +): [number, number] { + const normalizedBoundary: [number, number] = + boundary[0] <= boundary[1] ? [...boundary] : [boundary[1], boundary[0]] + const boundarySpan = normalizedBoundary[1] - normalizedBoundary[0] + if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return normalizedBoundary + const requestedStart = Math.min(domain[0], domain[1]) + const requestedEnd = Math.max(domain[0], domain[1]) + const minimumSpan = resolveMinimumZoomSpan(normalizedBoundary) + const span = Math.max(minimumSpan, Math.min(boundarySpan, requestedEnd - requestedStart)) + const center = (requestedStart + requestedEnd) / 2 + const start = Math.max( + normalizedBoundary[0], + Math.min(center - span / 2, normalizedBoundary[1] - span), + ) + return [start, start + span] +} + +function clampDomain(domain: [number, number], boundary: [number, number]): [number, number] { + const span = domain[1] - domain[0] + const boundarySpan = boundary[1] - boundary[0] + if (span >= boundarySpan) return [...boundary] + if (domain[0] < boundary[0]) return [boundary[0], boundary[0] + span] + if (domain[1] > boundary[1]) return [boundary[1] - span, boundary[1]] + return domain +} + +function currentYDomains(): Record { + return Object.fromEntries( + trackLayouts.value.map((track) => [ + track.series.trackId ?? track.series.id, + track.yScale.domain() as [number, number], + ]), + ) +} + +function beginViewportDrag(event: PointerEvent, trackIndex: number, independent: boolean) { + if (!props.zoomable || !isZoomMode.value || event.button !== 0) return + const overlay = event.currentTarget as SVGRectElement + const track = trackLayouts.value.find((item) => item.index === trackIndex) + if (!track) return + const [rawX, rawY] = pointer(event, overlay) + const x = Math.max(0, Math.min(independent ? track.width : innerWidth.value, rawX)) + const y = Math.max(0, Math.min(independent ? track.height : innerHeight.value, rawY)) + selection.value = { + trackIndex, + independent, + overlay, + startX: x, + startY: y, + currentX: x, + currentY: y, + pointerId: event.pointerId, + mode: spacePressed.value ? 'pan' : 'box', + xDomain: track.xScale.domain() as [number, number], + yDomains: currentYDomains(), + } + overlay.setPointerCapture?.(event.pointerId) + clearHover() + event.preventDefault() +} + +function beginSharedViewportDrag(event: PointerEvent) { + if (!sharedOverlayElement.value) return + const [x, y] = pointer(event, sharedOverlayElement.value) + const track = + resolveTrackAtPointer(x, y) ?? trackLayouts.value.find((item) => item.hasVisibleSeries) + if (track) beginViewportDrag(event, track.index, false) +} + +function updateViewportDrag(event: PointerEvent) { + 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 [rawX, rawY] = pointer(event, active.overlay) + active.currentX = Math.max(0, Math.min(active.independent ? track.width : innerWidth.value, rawX)) + active.currentY = Math.max( + 0, + Math.min(active.independent ? track.height : innerHeight.value, rawY), + ) + selection.value = { ...active } + if (active.mode === 'pan') applyPan(active, track) + event.preventDefault() +} + +function applyPan(active: SelectionState, track: TrackLayout) { + const width = track.width || 1 + const height = track.height || 1 + const dx = active.currentX - active.startX + const dy = active.currentY - active.startY + const xSpan = active.xDomain[1] - active.xDomain[0] + const initialDomain = active.independent ? resolveInitialTrackDomain(track) : initialXDomain.value + const nextX = clampDomain( + [active.xDomain[0] - (dx / width) * xSpan, active.xDomain[1] - (dx / width) * xSpan], + initialDomain, + ) + if (active.independent) { + const next = [...independentTransforms.value] + next[track.index] = transformForDomain(nextX, initialDomain, width) + independentTransforms.value = next + } else { + sharedTransform.value = transformForDomain(nextX, initialDomain, innerWidth.value) + } + + const targets = active.independent ? [track] : trackLayouts.value + targets.forEach((target) => { + const key = target.series.trackId ?? target.series.id + const source = active.yDomains[key] ?? (target.yScale.domain() as [number, number]) + const boundary = chartTracks.value.find((item) => item.id === key)?.yDomain ?? source + const ySpan = source[1] - source[0] + const nextY = clampDomain( + [source[0] + (dy / height) * ySpan, source[1] + (dy / height) * ySpan], + boundary, + ) + if (active.independent) + independentYDomains.value = { ...independentYDomains.value, [target.index]: nextY } + else sharedYDomains.value = { ...sharedYDomains.value, [key]: nextY } + }) + emit('zoom-change', nextX) +} + +function finishViewportDrag(event: PointerEvent) { + const active = selection.value + if (!active || event.pointerId !== active.pointerId) return + updateViewportDrag(event) + active.overlay.releasePointerCapture?.(active.pointerId) + selection.value = null + if (active.mode === 'pan') { + void nextTick(configureZoom) + return + } + const width = Math.abs(active.currentX - active.startX) + if (width < minimumSelectionSize) return + applyBoxZoom(active) +} + +function cancelViewportDrag(event?: PointerEvent) { + const active = selection.value + if (!active || (event && event.pointerId !== active.pointerId)) return + active.overlay.releasePointerCapture?.(active.pointerId) + selection.value = null +} + +function applyBoxZoom(active: SelectionState) { + const track = trackLayouts.value.find((item) => item.index === active.trackIndex) + if (!track || (active.independent ? !canZoomTrack(track) : !canZoomSharedTracks())) return + const offsetX = active.independent ? 0 : track.left + const left = Math.max( + 0, + Math.min(track.width, Math.min(active.startX, active.currentX) - offsetX), + ) + const right = Math.max( + 0, + Math.min(track.width, Math.max(active.startX, active.currentX) - offsetX), + ) + if (right - left < minimumSelectionSize) return + const baseXDomain = active.independent ? resolveInitialTrackDomain(track) : initialXDomain.value + const xDomain = constrainZoomDomain( + [track.xScale.invert(left), track.xScale.invert(right)], + baseXDomain, + ) + if (active.independent) { + const next = [...independentTransforms.value] + next[track.index] = transformForDomain(xDomain, baseXDomain, track.width) + independentTransforms.value = next + } else { + sharedTransform.value = transformForDomain(xDomain, baseXDomain, innerWidth.value) + } + + const targets = active.independent ? [track] : trackLayouts.value + const yRanges: Record = Object.fromEntries( + targets.map((target) => [ + target.series.trackId ?? target.series.id, + target.yScale.domain() as [number, number], + ]), + ) + + emit('zoom-change', xDomain) + const payload: WaveformZoomEndPayload = { start: xDomain[0], end: xDomain[1], gesture: 'box' } + if (active.independent) { + const yDomain = yRanges[track.series.trackId ?? track.series.id] + payload.trackIndex = track.index + payload.seriesIds = track.seriesList.map((series) => series.id) + payload.yStart = yDomain?.[0] + payload.yEnd = yDomain?.[1] + } else if (targets.length === 1) { + const yDomain = Object.values(yRanges)[0] + payload.yStart = yDomain?.[0] + payload.yEnd = yDomain?.[1] + } else payload.yRanges = yRanges + emit('zoom-end', payload) + void nextTick(configureZoom) +} + +function resetViewport(trackIndex?: number) { cancelPendingZoom() - sharedTransform.value = zoomIdentity - independentTransforms.value = chartTracks.value.map(() => zoomIdentity) + cancelViewportDrag() + if (props.displayMode === 'independent' && trackIndex !== undefined) { + const nextTransforms = [...independentTransforms.value] + nextTransforms[trackIndex] = zoomIdentity + independentTransforms.value = nextTransforms + const nextYDomains = { ...independentYDomains.value } + delete nextYDomains[trackIndex] + independentYDomains.value = nextYDomains + } else { + sharedTransform.value = zoomIdentity + independentTransforms.value = chartTracks.value.map(() => zoomIdentity) + sharedYDomains.value = {} + independentYDomains.value = {} + } clearHover() editorSeriesOptions.value = [] void nextTick(configureZoom) } +function requestViewportReset(event: MouseEvent) { + if (!props.zoomable || !isZoomMode.value) return + event.preventDefault() + resetViewport() + emit('zoom-reset') +} + +defineExpose({ resetViewport }) + function goToPage(page: number) { const nextPage = Math.min(pageCount.value, Math.max(1, Math.floor(page))) if (nextPage === currentPage.value) return @@ -1170,6 +1592,7 @@ function goToPage(page: number) { cancelAnnotation() if (props.displayMode === 'independent') { independentTransforms.value = pagedTracks.value.map(() => zoomIdentity) + independentYDomains.value = {} } void nextTick(configureZoom) emit('page-change', nextPage, pageCount.value) @@ -1180,6 +1603,9 @@ watch( innerWidth, innerHeight, () => props.zoomable, + () => props.minZoomSpan, + () => props.initialXDomain, + () => props.initialXDomains, () => props.displayMode, () => chartTracks.value.length, () => currentPage.value, @@ -1195,10 +1621,20 @@ watch( ) function handleDataReferenceChange() { - const previousPage = currentPage.value - currentPage.value = 1 - resetViewport() - if (previousPage !== 1) emit('page-change', 1, pageCount.value) + if (props.displayMode === 'independent') { + const currentTransforms = independentTransforms.value + independentTransforms.value = chartTracks.value.map( + (_track, index) => currentTransforms[index] ?? zoomIdentity, + ) + clearHover() + editorSeriesOptions.value = [] + void nextTick(configureZoom) + return + } + + clearHover() + editorSeriesOptions.value = [] + void nextTick(configureZoom) } watch( @@ -1321,6 +1757,8 @@ watch( ) onMounted(() => { + window.addEventListener('keydown', handleInteractionKeyDown) + window.addEventListener('keyup', handleInteractionKeyUp) if (!container.value) return resizeObserver.value = new ResizeObserver(([entry]) => { observedWidth.value = Math.max(0, entry?.contentRect.width ?? 0) @@ -1331,6 +1769,8 @@ onMounted(() => { }) onBeforeUnmount(() => { + window.removeEventListener('keydown', handleInteractionKeyDown) + window.removeEventListener('keyup', handleInteractionKeyUp) cancelPendingHover() resizeObserver.value?.disconnect() clearZoomBindings() @@ -1345,6 +1785,7 @@ onBeforeUnmount(() => { :class="[ `waveform-chart--${displayMode}`, `waveform-chart--interaction-${activeInteractionMode}`, + { 'waveform-chart--panning': selection?.mode === 'pan' }, ]" :style="containerStyle" :data-display-mode="displayMode" @@ -1388,6 +1829,7 @@ onBeforeUnmount(() => { :height="drawingHeight" role="img" :aria-label="hasWaveformData ? '波形折线图' : '暂无波形数据'" + @dblclick="requestViewportReset" > { :width="innerWidth" :height="innerHeight" @pointermove="handleSharedPointerMove" + @pointerdown="beginSharedViewportDrag" + @pointerup="finishViewportDrag" + @pointercancel="cancelViewportDrag" @pointerleave="clearHover" @click="handleAnnotationClick" @contextmenu="handleAnnotationContextMenu" @@ -1448,11 +1893,24 @@ onBeforeUnmount(() => { :y-label="yLabel" :hovered-point="hoveredSeriesPoints.find((p) => p.trackIndex === track.index)" @pointer-move="handleIndependentPointerMove($event, track.index)" + @pointer-down="beginViewportDrag($event, track.index, true)" + @pointer-up="finishViewportDrag" + @pointer-cancel="cancelViewportDrag" @pointer-leave="clearHover" @click="handleAnnotationClick($event, track.index)" @contextmenu="handleAnnotationContextMenu($event, track.index)" /> +