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 @@
+
+
+
+
+
+
+
固定振幅范围
+
当前范围:{{ rangeSummary }}
+
+
+ 自动
+ 全局固定
+ 按通道固定
+
+
+
+
+
+
+
+
+
+ 电压
+
+
+ 电流
+
+
+
+ 根据可见数据自动计算
+
+
+
+
+