diff --git a/README.md b/README.md index aa52768..75ad709 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,9 @@ 最新稳定版 Demo: +本地运行 `pnpm dev` 后,可通过 + 查看固定振幅上下限示例。 + ## 特性 - Vue 3 Composition API + TypeScript,支持按需导入 `WaveformChart` @@ -22,6 +25,7 @@ - 缩放过程事件、缩放结束按可视区间加载和视口重置 - 可选的空格拖拽平移,默认关闭并隔离多图表实例 - 多系列图例、受控显隐、网格分页和最多四根 Y 轴 +- 自动 Y 轴范围,以及全局、按轨道或按系列配置的固定振幅范围 - 按轨道控制水平/垂直网格线的显隐与颜色 - 受控标注、右键编辑、拖拽避让和自定义颜色 - 标题、图框、坐标轴、零值参考线、净图和渲染参数可配置 @@ -107,6 +111,8 @@ const data = ref({ | `minVisiblePoints` | `number` | `0` | 缩放后至少保留的不同 X 坐标数 | | `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围 | | `initialXDomains` | `Record` | 未设置 | 按 track/series ID 配置初始范围 | +| `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 | +| `yDomains` | `Record` | 未设置 | 按 track/series ID 配置固定范围 | | `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 | | `axes` | `WaveformAxesOptions` | 轴线均显示 | X/Y 轴基线显隐 | | `rendering` | `WaveformRenderingOptions` | `{}` | 降采样与点/误差棒间距 | @@ -177,6 +183,38 @@ import 'waveform-analysis/style.css' ``` +### 固定振幅上下限 + +未传入 Y 轴范围时,组件继续根据当前可见系列及其误差棒自动计算范围。传入 `yDomain` +后,所有波形使用同一个固定范围;超出范围的部分只在绘图区裁剪,不会过滤或修改原始数据: + +```vue + +``` + +多通道可以通过 `yDomains` 按稳定的 `trackId` 或 `seriesId` 分别配置: + +```vue + +``` + +范围优先级为 `trackId` 配置、`seriesId` 配置、全局 `yDomain`、数据自动范围。上下限必须 +是两个有限且不相等的数字;倒序范围会自动调整为升序,无效配置会回退到下一优先级。 +固定范围会精确作为坐标域使用,不会经过 D3 的 `nice()` 扩展。 + +单值轴叠加模式会合并同一根轴上所有可见系列的有效范围;多值轴模式按系列分别使用配置, +超过四根轴后复用第 4 根轴的系列会取范围并集。隐藏系列不参与公共范围合并。 + +固定范围存在时,对应图框的 Y 轴不会被平移或视口重置覆盖;X 轴缩放、平移和重置保持原有 +行为。运行时更新或移除 `yDomain` / `yDomains` 会立即重新布局,移除后恢复自动范围。 + ### 缩放后按可视区间加载数据 组件支持 Plotly 风格的矩形框选缩放:在 zoom 模式下按住鼠标左键拖拽,松开后同时缩放 @@ -589,7 +627,8 @@ X、Y 轴会根据各自完整显示域选择格式:最大绝对值在 `[0.01, - `src/index.ts`:组件库公开入口和工具函数导出 - `src/components/WaveformChart.vue`:图表容器、缩放、tooltip、图例和标注编排 - `src/components/{core,data,rendering,interaction,annotation}`:数据、布局、渲染和交互模块 -- `src/App.vue`:可交互 demo,`src/data` 中提供示例波形数据 +- `src/App.vue`:综合可交互 demo,`src/data` 中提供示例波形数据 +- `src/router.ts`:Demo 路由;`src/views/FixedYDomainDemo.vue` 为固定振幅范围示例 ## 本地开发 diff --git a/package.json b/package.json index 2086316..1065cd9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "waveform-analysis", - "version": "0.1.24", + "version": "0.1.25", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/types/index.d.ts", @@ -38,6 +38,7 @@ "dependencies": { "ant-design-vue": "3.2.20", "d3": "^7.9.0", + "vue-router": "^4.6.4", "vue3-colorpicker": "^2.3.0" }, "peerDependencies": { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e2d048..5cd7021 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: d3: specifier: ^7.9.0 version: 7.9.0 + vue-router: + specifier: ^4.6.4 + version: 4.6.4(vue@3.5.40(typescript@6.0.3)) vue3-colorpicker: specifier: ^2.3.0 version: 2.3.0(@aesoper/normal-utils@0.1.5)(@popperjs/core@2.11.8)(@vueuse/core@10.11.1(vue@3.5.40(typescript@6.0.3)))(gradient-parser@1.2.0)(lodash-es@4.18.1)(tinycolor2@1.6.0)(vue-types@4.2.1(vue@3.5.40(typescript@6.0.3)))(vue@3.5.40(typescript@6.0.3)) @@ -632,6 +635,9 @@ packages: '@vue/compiler-ssr@3.5.40': resolution: {integrity: sha512-rrE5xiXG663+vHCHa3J9p2z5OcBRjXmoqenprJxAFQxg5pSshzeBiCE6pu46axapRJ2Adk0YDA2BRZVjiHXnhg==} + '@vue/devtools-api@6.6.4': + resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} + '@vue/language-core@3.3.7': resolution: {integrity: sha512-LzmkKinXAMMoh8Jfi/jMUSDUjuPdv8mynH5WJGKfXyZtDw3hQ6GBaoI6Bcnl/Xqlu32q/0Z6i/trp4VXykzyLw==} @@ -1704,6 +1710,11 @@ packages: peerDependencies: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + vue-router@4.6.4: + resolution: {integrity: sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==} + peerDependencies: + vue: ^3.5.0 + vue-tsc@3.3.7: resolution: {integrity: sha512-+C+rgD49wAQ5bUTl2sp5a8Bzg4YoldMNXM+g7CFe604MYcQ8PrZPMQhIjJSzKXtPBCa+C5ayMipqjbA7splekQ==} hasBin: true @@ -2378,6 +2389,8 @@ snapshots: '@vue/compiler-dom': 3.5.40 '@vue/shared': 3.5.40 + '@vue/devtools-api@6.6.4': {} + '@vue/language-core@3.3.7': dependencies: '@volar/language-core': 2.4.28 @@ -3386,6 +3399,11 @@ snapshots: transitivePeerDependencies: - supports-color + vue-router@4.6.4(vue@3.5.40(typescript@6.0.3)): + dependencies: + '@vue/devtools-api': 6.6.4 + vue: 3.5.40(typescript@6.0.3) + vue-tsc@3.3.7(typescript@6.0.3): dependencies: '@volar/typescript': 2.4.28 diff --git a/src/DemoRouterApp.vue b/src/DemoRouterApp.vue new file mode 100644 index 0000000..3f71f78 --- /dev/null +++ b/src/DemoRouterApp.vue @@ -0,0 +1,14 @@ + diff --git a/src/components/core/fixedYDomainLayout.test.ts b/src/components/core/fixedYDomainLayout.test.ts new file mode 100644 index 0000000..ff26f7f --- /dev/null +++ b/src/components/core/fixedYDomainLayout.test.ts @@ -0,0 +1,116 @@ +import { zoomIdentity } from 'd3' +import { describe, expect, it } from 'vitest' + +import { DEFAULT_WAVEFORM_RENDERING_OPTIONS } from '../../core' +import type { DisplaySeries, DisplayTrack } from './types' +import { buildTrackLayouts, resolveYAxisSeriesGroups } from './layout' + +function series(id: string, minimum: number, maximum: number): DisplaySeries { + return { + id, + name: id, + color: '#1677ff', + lineType: 'linear', + lineStyle: 'solid', + pointType: 'none', + errorBar: { visible: false, width: 1.5, capWidth: 8 }, + points: [ + { x: 0, y: minimum }, + { x: 1, y: maximum }, + ], + xDomain: [0, 1], + yDomain: [minimum, maximum], + hasErrorPoints: false, + } +} + +function track(seriesList: DisplaySeries[]): DisplayTrack { + return { + id: 'track', + series: seriesList, + visibleSeries: seriesList, + xDomain: [0, 1], + yDomain: [0, 100], + } +} + +describe('fixed Y-domain layout', () => { + it('uses an exact global fixed domain without applying nice bounds', () => { + const sourceTrack = track([series('a', 0, 100)]) + const result = buildTrackLayouts({ + cells: [ + { + slotIndex: 0, + row: 0, + column: 0, + left: 0, + top: 0, + width: 120, + height: 100, + plotHeight: 100, + cellHeight: 130, + xAxisBand: 30, + series: sourceTrack, + }, + ], + grid: { rowCount: 1, columnCount: 1, showPagination: false, trackLines: {} }, + displayMode: 'independent', + overlayMode: 'single-axis', + independentTransforms: [zoomIdentity], + sharedZoomDomain: [0, 1], + fixedYDomain: [3, 97], + timeUnit: 'ms', + rendering: DEFAULT_WAVEFORM_RENDERING_OPTIONS, + hideSecondaryLabels: false, + yAxisLabelX: -50, + showCompactEmptyTracks: false, + })[0] + + expect(result?.yScale.domain()).toEqual([3, 97]) + expect(result?.yAxes[0]?.tickValues).toContain(3) + expect(result?.yAxes[0]?.tickValues).toContain(97) + expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([3, 97]) + }) + + it('resolves track, series, and global fixed-domain precedence', () => { + const sourceTrack = track([series('a', 0, 10), series('b', 20, 30)]) + + expect( + resolveYAxisSeriesGroups(sourceTrack, 'single-axis', [-5, 5], { + a: [-10, 10], + track: [300, 100], + })[0]?.domain, + ).toEqual([100, 300]) + + expect( + resolveYAxisSeriesGroups(sourceTrack, 'single-axis', [-5, 5], { + a: [-10, 10], + })[0]?.domain, + ).toEqual([-10, 10]) + }) + + it('keeps per-series fixed domains on separate axes and merges axis overflow', () => { + const sourceTrack = track([ + series('a', 0, 1), + series('b', 10, 11), + series('c', 20, 21), + series('d', 30, 31), + series('e', 40, 41), + ]) + const groups = resolveYAxisSeriesGroups(sourceTrack, 'multi-axis', undefined, { + a: [-1, 1], + b: [-2, 2], + c: [-3, 3], + d: [-4, 4], + e: [-5, 5], + }) + + expect(groups.map((group) => group.domain)).toEqual([ + [-1, 1], + [-2, 2], + [-3, 3], + [-5, 5], + ]) + expect(groups.every((group) => group.fixed)).toBe(true) + }) +}) diff --git a/src/components/core/layout.ts b/src/components/core/layout.ts index 72165ba..e0b85a9 100644 --- a/src/components/core/layout.ts +++ b/src/components/core/layout.ts @@ -4,6 +4,12 @@ import type { WaveformOverlayMode } from '../../types' import { formatScientificAxisExponent, formatScientificAxisLabel, paddedDomain } from '../../utils' import type { DisplaySeries, DisplayTrack, TrackLayout } from './types' import { MAX_MULTI_Y_AXIS_COUNT, Y_AXIS_EXPONENT_GAP } from './constants' +import { + mergeYDomains, + resolveSeriesFixedYDomain, + resolveTrackFixedYDomain, + type WaveformYDomain, +} from './yDomain' // 导出常量供外部使用 export { MAX_MULTI_Y_AXIS_COUNT, Y_AXIS_EXPONENT_GAP } from './constants' @@ -14,11 +20,12 @@ const Y_AXIS_OUTER_PADDING = 4 const Y_AXIS_LABEL_GAP = 6 const Y_AXIS_LABEL_BAND_WIDTH = 24 -interface YAxisSeriesGroup { +export interface YAxisSeriesGroup { index: number side: 'left' | 'right' seriesList: DisplaySeries[] domain: [number, number] + fixed: boolean } function resolveAxisSides(axisCount: number): Array<'left' | 'right'> { @@ -53,6 +60,7 @@ export function buildYAxisSeriesGroups( side: sides[index], seriesList: [] as DisplaySeries[], domain: [0, 1] as [number, number], + fixed: false, })) track.visibleSeries.forEach((series, index) => { @@ -72,12 +80,35 @@ export function buildYAxisSeriesGroups( return grouped } -export function axisTextMetrics(domain: [number, number]): { +export function resolveYAxisSeriesGroups( + track: DisplayTrack, + overlayMode: WaveformOverlayMode, + yDomain?: WaveformYDomain, + yDomains?: Record, +): YAxisSeriesGroup[] { + const trackDomain = resolveTrackFixedYDomain(track, yDomains) + return buildYAxisSeriesGroups(track, overlayMode).map((group) => { + if (trackDomain) return { ...group, domain: trackDomain, fixed: true } + const seriesDomains = group.seriesList.map( + (series) => resolveSeriesFixedYDomain(series, yDomain, yDomains) ?? series.yDomain, + ) + const fixed = group.seriesList.some((series) => + resolveSeriesFixedYDomain(series, yDomain, yDomains), + ) + return fixed ? { ...group, domain: mergeYDomains(seriesDomains), fixed } : group + }) +} + +export function axisTextMetrics( + domain: [number, number], + nice = true, +): { exponentLabel: string | null exponentWidth: number tickTextWidth: number } { - const scale = scaleLinear(domain, [1, 0]).nice() + const scale = scaleLinear(domain, [1, 0]) + if (nice) scale.nice() const [axisMin, axisMax] = scale.domain() const values = scale.ticks(10) const maximumTickCharacters = Math.max( @@ -92,15 +123,15 @@ export function axisTextMetrics(domain: [number, number]): { } } -function axisExponentClearance(domain: [number, number]): number { - const { exponentLabel, exponentWidth } = axisTextMetrics(domain) +function axisExponentClearance(domain: [number, number], nice: boolean): number { + const { exponentLabel, exponentWidth } = axisTextMetrics(domain, nice) return exponentLabel ? exponentWidth + Y_AXIS_EXPONENT_GAP : 0 } export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number { return ( - axisTextMetrics(group.domain).tickTextWidth + - axisExponentClearance(group.domain) + + axisTextMetrics(group.domain, !group.fixed).tickTextWidth + + axisExponentClearance(group.domain, !group.fixed) + Y_AXIS_TICK_PADDING + Y_AXIS_LABEL_GAP + Y_AXIS_LABEL_BAND_WIDTH + @@ -110,8 +141,8 @@ export function measureYAxisGroupClearance(group: YAxisSeriesGroup): number { function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number { return ( - axisTextMetrics(group.domain).tickTextWidth + - axisExponentClearance(group.domain) + + axisTextMetrics(group.domain, !group.fixed).tickTextWidth + + axisExponentClearance(group.domain, !group.fixed) + Y_AXIS_TICK_PADDING + Y_AXIS_OUTER_PADDING ) @@ -120,8 +151,10 @@ function measureYAxisGroupTickClearance(group: YAxisSeriesGroup): number { export function measureTrackYAxisClearance( track: DisplayTrack, overlayMode: WaveformOverlayMode, + yDomain?: WaveformYDomain, + yDomains?: Record, ): { left: number; right: number } { - return buildYAxisSeriesGroups(track, overlayMode).reduce( + return resolveYAxisSeriesGroups(track, overlayMode, yDomain, yDomains).reduce( (clearance, group) => { clearance[group.side] += overlayMode === 'multi-axis' || track.visibleSeries.length === 1 diff --git a/src/components/core/trackLayoutBuilder.ts b/src/components/core/trackLayoutBuilder.ts index 1dbb430..66ba3be 100644 --- a/src/components/core/trackLayoutBuilder.ts +++ b/src/components/core/trackLayoutBuilder.ts @@ -19,7 +19,7 @@ import { type GridCellGeometry, type NormalizedWaveformGridOptions, } from './grid' -import { axisTextMetrics, buildYAxisSeriesGroups } from './layout' +import { axisTextMetrics, resolveYAxisSeriesGroups } from './layout' import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types' import { Y_AXIS_EXPONENT_GAP } from './constants' import { @@ -42,6 +42,8 @@ export interface BuildTrackLayoutsOptions { sharedZoomDomain: [number, number] initialXDomain?: [number, number] initialXDomains?: Record + fixedYDomain?: [number, number] + fixedYDomains?: Record yDomains?: Record timeUnit: 's' | 'ms' rendering: ResolvedWaveformRenderingOptions @@ -94,13 +96,18 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou : zoomIdentity const xScale = transform.rescaleX(baseXScale) const configuredYDomain = options.yDomains?.[displayTrack.id] - const yAxisGroups = buildYAxisSeriesGroups(displayTrack, options.overlayMode).map((group) => ({ - ...group, - domain: configuredYDomain ?? group.domain, - })) + const yAxisGroups = resolveYAxisSeriesGroups( + displayTrack, + options.overlayMode, + options.fixedYDomain, + options.fixedYDomains, + ).map((group) => + !group.fixed && configuredYDomain ? { ...group, domain: configuredYDomain } : group, + ) const sideOffsets = { left: 0, right: 0 } const yAxes: WaveformYAxisLayout[] = yAxisGroups.map((group) => { - const scale = scaleLinear(group.domain, [cell.plotHeight, 0]).nice() + const scale = scaleLinear(group.domain, [cell.plotHeight, 0]) + if (!group.fixed) scale.nice() const majorTicks = scale.ticks(Math.max(2, Math.floor(cell.plotHeight / 55))) const [axisStart, axisEnd] = scale.domain() const showAxisEnd = options.displayMode !== 'compact' || cell.row === 0 @@ -110,7 +117,10 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou const tickValues = Array.from( new Set([axisStart, ...visibleMajorTicks, ...(showAxisEnd ? [axisEnd] : [])]), ) - const { exponentLabel, exponentWidth, tickTextWidth } = axisTextMetrics(group.domain) + const { exponentLabel, exponentWidth, tickTextWidth } = axisTextMetrics( + group.domain, + !group.fixed, + ) const exponentClearance = exponentLabel ? exponentWidth + Y_AXIS_EXPONENT_GAP : 0 const clearance = tickTextWidth + diff --git a/src/components/core/useWaveformChartController.ts b/src/components/core/useWaveformChartController.ts index 563f8ce..4a77c85 100644 --- a/src/components/core/useWaveformChartController.ts +++ b/src/components/core/useWaveformChartController.ts @@ -6,6 +6,7 @@ import { shallowReactive, shallowRef, toRefs, + watch, watchEffect, type ComponentPublicInstance, type Ref, @@ -68,6 +69,15 @@ export function useWaveformChartController( const annotationInteraction = useWaveformAnnotationInteraction() const editorSeriesOptions = ref([]) const hoverThrottle = useAnimationFrameThrottle() + + watch( + [() => props.yDomain, () => props.yDomains], + () => { + sharedYDomains.value = {} + independentYDomains.value = {} + }, + { deep: true }, + ) const selection = ref(null) const spacePressed = ref(false) const pointerInsideChart = ref(false) diff --git a/src/components/core/useWaveformLayout.ts b/src/components/core/useWaveformLayout.ts index 5444900..090ca93 100644 --- a/src/components/core/useWaveformLayout.ts +++ b/src/components/core/useWaveformLayout.ts @@ -26,7 +26,12 @@ import { resolveGridCellGeometry, X_AXIS_BAND, } from './grid' -import { buildTrackLayouts, measureTrackYAxisClearance, Y_AXIS_EXPONENT_GAP } from './layout' +import { + buildTrackLayouts, + measureTrackYAxisClearance, + resolveYAxisSeriesGroups, + Y_AXIS_EXPONENT_GAP, +} from './layout' import type { DisplaySeries, DisplayTrack, TrackLayout } from './types' import type { PreparedWaveformSeries } from './useWaveformData' import type { ResolvedWaveformChartProps } from './waveformChartTypes' @@ -104,8 +109,12 @@ export function useWaveformLayout(context: LayoutContext) { const yAxisMetrics = computed(() => { const axisText = chartTracks.value .filter((track) => track.visibleSeries.length > 0) - .map((track) => { - const scale = scaleLinear(track.yDomain, [1, 0]).nice() + .flatMap((track) => + resolveYAxisSeriesGroups(track, props.overlayMode, props.yDomain, props.yDomains), + ) + .map((group) => { + const scale = scaleLinear(group.domain, [1, 0]) + if (!group.fixed) scale.nice() const [axisMin, axisMax] = scale.domain() return { exponentLabel: formatScientificAxisExponent(axisMin, axisMax), @@ -165,7 +174,12 @@ export function useWaveformLayout(context: LayoutContext) { const multiAxisClearance = computed(() => chartTracks.value.reduce( (maximum, track) => { - const clearance = measureTrackYAxisClearance(track, props.overlayMode) + const clearance = measureTrackYAxisClearance( + track, + props.overlayMode, + props.yDomain, + props.yDomains, + ) return { left: Math.max(maximum.left, clearance.left), right: Math.max(maximum.right, clearance.right), @@ -281,6 +295,8 @@ export function useWaveformLayout(context: LayoutContext) { sharedZoomDomain: sharedZoomDomain.value, initialXDomain: props.initialXDomain ? initialXDomain.value : undefined, initialXDomains: props.initialXDomains, + fixedYDomain: props.yDomain, + fixedYDomains: props.yDomains, yDomains: props.displayMode === 'independent' ? Object.fromEntries( diff --git a/src/components/core/waveformChartTypes.ts b/src/components/core/waveformChartTypes.ts index 798467c..448b2b6 100644 --- a/src/components/core/waveformChartTypes.ts +++ b/src/components/core/waveformChartTypes.ts @@ -31,6 +31,8 @@ export interface WaveformChartProps { minVisiblePoints?: number initialXDomain?: [number, number] initialXDomains?: Record + yDomain?: [number, number] + yDomains?: Record timeUnit?: 's' | 'ms' frameNumber?: string | number frameStyle?: WaveformFrameStyle diff --git a/src/components/core/yDomain.test.ts b/src/components/core/yDomain.test.ts new file mode 100644 index 0000000..a6959ba --- /dev/null +++ b/src/components/core/yDomain.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest' + +import type { DisplayTrack } from './types' +import { hasFixedYDomainForTrack, mergeYDomains, normalizeYDomain } from './yDomain' + +const track: DisplayTrack = { + id: 'shared-track', + series: [], + visibleSeries: [ + { + id: 'channel-a', + name: 'A', + color: '#1677ff', + lineType: 'linear', + lineStyle: 'solid', + pointType: 'none', + errorBar: { visible: false, width: 1.5, capWidth: 8 }, + points: [], + xDomain: [0, 1], + yDomain: [0, 1], + hasErrorPoints: false, + }, + ], + xDomain: [0, 1], + yDomain: [0, 1], +} + +describe('fixed Y domains', () => { + it('normalizes valid reversed domains', () => { + expect(normalizeYDomain([97, 3])).toEqual([3, 97]) + expect(normalizeYDomain([-10, 10])).toEqual([-10, 10]) + }) + + it.each([undefined, [1, 1], [Number.NaN, 1], [0, Number.POSITIVE_INFINITY]])( + 'rejects an invalid domain: %j', + (domain) => { + expect(normalizeYDomain(domain as [number, number] | undefined)).toBeUndefined() + }, + ) + + it('detects valid track, series, and global configuration only', () => { + expect(hasFixedYDomainForTrack(track, [-1, 1])).toBe(true) + expect(hasFixedYDomainForTrack(track, undefined, { 'shared-track': [-2, 2] })).toBe(true) + expect(hasFixedYDomainForTrack(track, undefined, { 'channel-a': [-3, 3] })).toBe(true) + expect(hasFixedYDomainForTrack(track, [0, 0], { 'channel-a': [1, 1] })).toBe(false) + }) + + it('merges configured and automatic series domains without padding', () => { + expect( + mergeYDomains([ + [3, 97], + [-20, 40], + ]), + ).toEqual([-20, 97]) + }) +}) diff --git a/src/components/core/yDomain.ts b/src/components/core/yDomain.ts new file mode 100644 index 0000000..bfa6521 --- /dev/null +++ b/src/components/core/yDomain.ts @@ -0,0 +1,50 @@ +import type { DisplaySeries, DisplayTrack } from './types' + +export type WaveformYDomain = [number, number] + +export function normalizeYDomain( + domain: readonly [number, number] | undefined, +): WaveformYDomain | undefined { + if ( + !domain || + !Number.isFinite(domain[0]) || + !Number.isFinite(domain[1]) || + domain[0] === domain[1] + ) { + return undefined + } + return domain[0] < domain[1] ? [domain[0], domain[1]] : [domain[1], domain[0]] +} + +export function resolveTrackFixedYDomain( + track: Pick, + yDomains?: Record, +): WaveformYDomain | undefined { + return normalizeYDomain(yDomains?.[track.id]) +} + +export function resolveSeriesFixedYDomain( + series: Pick, + yDomain?: WaveformYDomain, + yDomains?: Record, +): WaveformYDomain | undefined { + return normalizeYDomain(yDomains?.[series.id]) ?? normalizeYDomain(yDomain) +} + +export function hasFixedYDomainForTrack( + track: Pick, + yDomain?: WaveformYDomain, + yDomains?: Record, +): boolean { + return Boolean( + resolveTrackFixedYDomain(track, yDomains) || + track.visibleSeries.some((series) => resolveSeriesFixedYDomain(series, yDomain, yDomains)), + ) +} + +export function mergeYDomains(domains: readonly WaveformYDomain[]): WaveformYDomain { + return [ + Math.min(...domains.map((domain) => domain[0])), + Math.max(...domains.map((domain) => domain[1])), + ] +} diff --git a/src/components/interaction/useWaveformViewport.ts b/src/components/interaction/useWaveformViewport.ts index e055bf2..741299e 100644 --- a/src/components/interaction/useWaveformViewport.ts +++ b/src/components/interaction/useWaveformViewport.ts @@ -3,6 +3,7 @@ import { computed, nextTick, type ComputedRef, type Ref, type ShallowRef } from import { MINIMUM_SELECTION_SIZE } from '../core/constants' import type { DisplayTrack, TrackLayout } from '../core/types' +import { hasFixedYDomainForTrack } from '../core/yDomain' import type { ResolvedWaveformChartProps, ViewportSelectionState, @@ -190,9 +191,13 @@ export function useWaveformViewport(context: ViewportContext) { const nextIndependentDomains = { ...independentYDomains.value } const nextSharedDomains = { ...sharedYDomains.value } targets.forEach((target) => { + const chartTrack = chartTracks.value.find((item) => item.id === target.id) + if (chartTrack && hasFixedYDomainForTrack(chartTrack, props.yDomain, props.yDomains)) { + return + } 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 boundary = chartTrack?.yDomain ?? source const ySpan = source[1] - source[0] const nextY = clampDomain( [source[0] + (dy / height) * ySpan, source[1] + (dy / height) * ySpan], diff --git a/src/components/waveformChartCases/layoutAndDomains.test.ts b/src/components/waveformChartCases/layoutAndDomains.test.ts index eb67164..cd4f7cc 100644 --- a/src/components/waveformChartCases/layoutAndDomains.test.ts +++ b/src/components/waveformChartCases/layoutAndDomains.test.ts @@ -294,6 +294,31 @@ describe('WaveformChart', () => { expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00') }) + it('reacts to exact fixed Y-domain props and returns to automatic bounds', async () => { + const wrapper = await mountSizedChart({ + kind: 'points', + points: [ + { x: 0, y: 0 }, + { x: 1, y: 100 }, + ], + }) + const yTickLabels = () => + wrapper + .get('.waveform-chart__axis--y') + .findAll('.tick text') + .map((tick) => tick.text()) + + await wrapper.setProps({ yDomain: [3, 97] }) + await flushPromises() + expect(yTickLabels()).toContain('3.00') + expect(yTickLabels()).toContain('97.00') + + await wrapper.setProps({ yDomain: undefined }) + await flushPromises() + expect(yTickLabels()).not.toContain('3.00') + expect(yTickLabels()).not.toContain('97.00') + }) + it('keeps annotations bound to their channel while paging', async () => { const wrapper = await mountSizedChart(gridSeries(3), { grid: { rowCount: 1, columnCount: 1 }, diff --git a/src/components/waveformChartCases/viewportBoxAndPan.test.ts b/src/components/waveformChartCases/viewportBoxAndPan.test.ts index a5d1d74..33091d7 100644 --- a/src/components/waveformChartCases/viewportBoxAndPan.test.ts +++ b/src/components/waveformChartCases/viewportBoxAndPan.test.ts @@ -224,6 +224,56 @@ describe('WaveformChart', () => { window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' })) }) + it('keeps a fixed Y domain through panning and viewport reset', async () => { + const wrapper = await mountSizedChart( + { + kind: 'points', + points: [ + { x: 0, y: 0 }, + { x: 1, y: 100 }, + ], + }, + { pannable: true, yDomain: [3, 97] }, + ) + 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 yTickLabels = () => + wrapper + .get('.waveform-chart__axis--y') + .findAll('.tick text') + .map((tick) => tick.text()) + const initialLabels = yTickLabels() + + await wrapper.trigger('pointerenter') + window.dispatchEvent(new KeyboardEvent('keydown', { code: 'Space', cancelable: true })) + const down = new MouseEvent('pointerdown', { + button: 0, + clientX: width / 2, + clientY: height / 2, + bubbles: true, + }) + Object.defineProperty(down, 'pointerId', { value: 34 }) + overlay.element.dispatchEvent(down) + const move = new MouseEvent('pointermove', { + clientX: width / 2 + 20, + clientY: height / 2 + 40, + bubbles: true, + }) + Object.defineProperty(move, 'pointerId', { value: 34 }) + overlay.element.dispatchEvent(move) + await flushPromises() + + expect(yTickLabels()).toEqual(initialLabels) + ;(wrapper.vm as unknown as { resetViewport: () => void }).resetViewport() + await flushPromises() + expect(yTickLabels()).toEqual(initialLabels) + window.dispatchEvent(new KeyboardEvent('keyup', { code: 'Space' })) + }) + it('does not activate pannable on a chart that the pointer is outside', async () => { const data: WaveformData = { kind: 'points', diff --git a/src/demoRoutes.css b/src/demoRoutes.css new file mode 100644 index 0000000..579d36b --- /dev/null +++ b/src/demoRoutes.css @@ -0,0 +1,197 @@ +.demo-shell { + display: grid; + grid-template-rows: 48px minmax(0, 1fr); + width: 100%; + height: 100%; + overflow: hidden; +} + +.demo-shell__header { + z-index: 40; + display: flex; + gap: 24px; + align-items: center; + min-width: 0; + padding: 0 16px; + background: #fff; + border-bottom: 1px solid #e4e7ec; +} + +.demo-shell__brand { + flex: 0 0 auto; + color: #101828; + font-size: 14px; + font-weight: 700; + text-decoration: none; +} + +.demo-shell__nav { + display: flex; + align-self: stretch; + min-width: 0; +} + +.demo-shell__nav a { + display: inline-flex; + align-items: center; + padding: 0 14px; + color: #475467; + font-size: 13px; + text-decoration: none; + border-bottom: 2px solid transparent; +} + +.demo-shell__nav a:hover, +.demo-shell__nav a:focus-visible, +.demo-shell__nav a.router-link-exact-active { + color: #0958d9; +} + +.demo-shell__nav a.router-link-exact-active { + background: #f0f7ff; + border-bottom-color: #1677ff; +} + +.demo-shell__content { + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.fixed-domain-demo { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + gap: 12px; + width: 100%; + height: 100%; + padding: 16px; + overflow: hidden; +} + +.fixed-domain-demo__toolbar { + display: flex; + gap: 24px; + align-items: center; + justify-content: space-between; + min-width: 0; +} + +.fixed-domain-demo__toolbar h1 { + margin: 0; + color: #101828; + font-size: 18px; + font-weight: 700; + letter-spacing: 0; +} + +.fixed-domain-demo__toolbar p { + margin: 4px 0 0; + color: #667085; + font-size: 12px; +} + +.fixed-domain-demo__range-controls { + display: flex; + gap: 16px; + align-items: center; + min-height: 48px; + padding: 8px 12px; + background: #fff; + border: 1px solid #e4e7ec; + border-radius: 6px; +} + +.fixed-domain-demo__range-controls label { + display: flex; + gap: 8px; + align-items: center; + color: #475467; + font-size: 12px; +} + +.fixed-domain-demo__range-controls .ant-input-number { + width: 112px; +} + +.fixed-domain-demo__range-controls strong { + color: #344054; + font-size: 12px; +} + +.fixed-domain-demo__range-controls strong:not(:first-child) { + padding-left: 16px; + border-left: 1px solid #e4e7ec; +} + +.fixed-domain-demo__auto-state { + color: #667085; + font-size: 12px; +} + +.fixed-domain-demo__chart { + min-width: 0; + min-height: 0; + overflow: hidden; +} + +.fixed-domain-demo__chart > .waveform-chart { + min-height: 0; +} + +@media (max-width: 900px) { + .demo-shell { + grid-template-rows: 44px minmax(0, 1fr); + } + + .demo-shell__header { + gap: 8px; + padding: 0 8px; + } + + .demo-shell__brand { + display: none; + } + + .demo-shell__nav { + width: 100%; + } + + .demo-shell__nav a { + flex: 1 1 0; + justify-content: center; + padding: 0 6px; + } + + .fixed-domain-demo { + gap: 8px; + padding: 10px; + } + + .fixed-domain-demo__toolbar { + display: grid; + gap: 8px; + } + + .fixed-domain-demo__toolbar .ant-radio-group { + display: flex; + width: 100%; + } + + .fixed-domain-demo__toolbar .ant-radio-button-wrapper { + flex: 1 1 0; + padding-inline: 6px; + text-align: center; + } + + .fixed-domain-demo__range-controls { + flex-wrap: wrap; + gap: 8px 12px; + } + + .fixed-domain-demo__range-controls strong:not(:first-child) { + width: 100%; + padding: 8px 0 0; + border-top: 1px solid #e4e7ec; + border-left: 0; + } +} diff --git a/src/main.ts b/src/main.ts index f27bb3e..e51e250 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,7 +1,9 @@ import { createApp } from 'vue' import 'ant-design-vue/dist/antd.css' -import App from './App.vue' +import DemoRouterApp from './DemoRouterApp.vue' +import { router } from './router' +import './demoRoutes.css' import './styles.css' -createApp(App).mount('#app') +createApp(DemoRouterApp).use(router).mount('#app') diff --git a/src/router.ts b/src/router.ts new file mode 100644 index 0000000..58fea4e --- /dev/null +++ b/src/router.ts @@ -0,0 +1,21 @@ +import { createRouter, createWebHashHistory, type RouteRecordRaw } from 'vue-router' + +import App from './App.vue' + +export const routes: RouteRecordRaw[] = [ + { + path: '/', + name: 'workspace', + component: App, + }, + { + path: '/fixed-y-domain', + name: 'fixed-y-domain', + component: () => import('./views/FixedYDomainDemo.vue'), + }, +] + +export const router = createRouter({ + history: createWebHashHistory(), + routes, +}) diff --git a/src/styles.css b/src/styles.css index ef3283a..25b7dd3 100644 --- a/src/styles.css +++ b/src/styles.css @@ -35,8 +35,7 @@ body { grid-template-columns: 280px minmax(0, 1fr); gap: 12px; width: 100%; - height: 100vh; - height: 100dvh; + height: 100%; padding: 12px; overflow: hidden; } diff --git a/src/views/FixedYDomainDemo.test.ts b/src/views/FixedYDomainDemo.test.ts new file mode 100644 index 0000000..379042d --- /dev/null +++ b/src/views/FixedYDomainDemo.test.ts @@ -0,0 +1,44 @@ +import { flushPromises, mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' + +import { WaveformChart } from '../components' +import { routes } from '../router' +import FixedYDomainDemo from './FixedYDomainDemo.vue' + +describe('fixed Y-domain demo route', () => { + it('registers the example route', () => { + expect(routes).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + path: '/fixed-y-domain', + name: 'fixed-y-domain', + }), + ]), + ) + }) + + it('switches between global, automatic, and per-channel ranges', async () => { + const wrapper = mount(FixedYDomainDemo) + await flushPromises() + const chart = () => wrapper.getComponent(WaveformChart) + const modeInputs = wrapper.get('[aria-label="Y 轴范围模式"]').findAll('input[type="radio"]') + + expect(chart().props('yDomain')).toEqual([-80, 80]) + expect(chart().props('yDomains')).toBeUndefined() + + await modeInputs[0]?.setValue(true) + await flushPromises() + expect(chart().props('yDomain')).toBeUndefined() + expect(chart().props('yDomains')).toBeUndefined() + + await modeInputs[2]?.setValue(true) + await flushPromises() + expect(chart().props('yDomain')).toBeUndefined() + expect(chart().props('yDomains')).toEqual({ + voltage: [-65, 65], + current: [-260, 260], + }) + + wrapper.unmount() + }) +}) diff --git a/src/views/FixedYDomainDemo.vue b/src/views/FixedYDomainDemo.vue new file mode 100644 index 0000000..bab19a2 --- /dev/null +++ b/src/views/FixedYDomainDemo.vue @@ -0,0 +1,149 @@ + + +