diff --git a/README.md b/README.md index 5a83bd7..091df4e 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ const data = ref({ | `minVisiblePoints` | `number` | `0` | 缩放后至少保留的不同 X 坐标数 | | `initialXDomain` | `[number, number]` | 未设置 | 所有图框的初始 X 范围 | | `initialXDomains` | `Record` | 未设置 | 按 track/series ID 配置初始范围 | +| `xDomainStrategy` | `WaveformXDomainStrategy` | `{ type: 'data' }` | 自动 X 轴视口范围策略 | | `yDomain` | `[number, number]` | 未设置 | 所有波形的固定 Y 轴范围 | | `yDomains` | `Record` | 未设置 | 按 track/series ID 配置固定范围 | | `grid` | `WaveformGridOptions` | `{ rowCount: 2, columnCount: 1, showPagination: true }` | 网格和分页 | @@ -129,7 +130,7 @@ const data = ref({ 所有公开类型均可从包入口导入,例如 `WaveformData`、`WaveformSeries`、 `WaveformAnnotation`、`WaveformLineStyle`、`WaveformRenderingOptions`、 -`WaveformAxesOptions`、`WaveformXAxisLabelFormatter`、`WaveformZeroLineOptions`、 +`WaveformAxesOptions`、`WaveformXAxisLabelFormatter`、`WaveformXDomainStrategy`、`WaveformZeroLineOptions`、 `WaveformGridOptions` 和 `WaveformGridTrackLines`。 ### 数据结构 @@ -252,6 +253,26 @@ X 轴且包含多个轨道时使用按稳定 track ID 索引的 `yRanges`。平 重置组件内部缩放并触发 `zoom-reset`;调用方应在事件中取消区间请求并恢复首次完整数据。 外部重置按钮也可以通过模板引用调用组件公开的 `resetViewport()` 方法,然后执行相同的数据恢复逻辑。 +没有显式配置初始范围时,可以通过 `xDomainStrategy` 将数据范围扩展为便于阅读的视口端点。 +默认的 `{ type: 'data' }` 保持数据最小值和最大值不变;`type: 'nice'` 使用固定刻度数量计算 +易读边界,且只扩展视口,不修改原始点位、tooltip、标注或缩放事件值: + +```vue + +``` + +例如秒坐标数据范围为 `[0, 4.999999]` 且 `timeUnit='ms'` 时,上述配置会使用 +`[0, 5]` 作为初始及重置视口,两端 label 显示 `0` 和 `5000`。`bounds: 'end'` +只扩展右端;默认的 `bounds: 'both'` +会同时扩展两端。`tickCount` 默认为 `10`,只参与边界计算,不随组件宽度变化。 +`initialXDomains`、`initialXDomain` 的显式配置默认保持原值;仅当 `includeExplicit: true` 时 +也应用 nice 扩展。独立模式在未配置显式范围时 +按图框分别计算,共享 X 轴模式则合并所有可见图框后计算。所有范围仍使用原始秒坐标, +`timeUnit` 只影响显示。 + 独立坐标模式下,回填响应应只替换 `seriesIds` 对应的系列,并调用 `resetViewport(trackIndex)`;其他图框的数据和缩放状态应保持不变。独立模式下双击图框触发的 `zoom-reset` payload 会包含该图框的 `trackIndex` 和 `seriesIds`;共享 X 范围模式的 payload diff --git a/package.json b/package.json index df3e3f7..d8363e5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "waveform-analysis", - "version": "0.1.32", + "version": "0.1.33", "main": "./dist/index.cjs", "module": "./dist/index.js", "types": "./dist/types/index.d.ts", diff --git a/src/components/WaveformChart.vue b/src/components/WaveformChart.vue index 3594fa6..1b8338a 100644 --- a/src/components/WaveformChart.vue +++ b/src/components/WaveformChart.vue @@ -16,6 +16,7 @@ const props = withDefaults(defineProps(), { zoomable: true, pannable: false, minVisiblePoints: 0, + xDomainStrategy: () => ({ type: 'data' }), timeUnit: 'ms', frameNumber: undefined, annotations: () => [], diff --git a/src/components/core/trackLayoutBuilder.ts b/src/components/core/trackLayoutBuilder.ts index b409df5..db1a04e 100644 --- a/src/components/core/trackLayoutBuilder.ts +++ b/src/components/core/trackLayoutBuilder.ts @@ -16,6 +16,7 @@ import type { WaveformDisplayMode, WaveformOverlayMode, WaveformPoint, + WaveformXDomainStrategy, WaveformXAxisLabelFormatter, } from '../../types' import { buildMinorTicks, formatXAxisLabel } from '../../utils' @@ -26,6 +27,7 @@ import { } from './grid' import { axisTextMetrics, resolveYAxisSeriesGroups } from './layout' import type { DisplaySeries, DisplayTrack, TrackLayout, WaveformYAxisLayout } from './types' +import { applyXDomainStrategy } from './xDomain' import { Y_AXIS_LABEL_BAND_WIDTH, Y_AXIS_LABEL_GAP, @@ -46,6 +48,7 @@ export interface BuildTrackLayoutsOptions { sharedZoomDomain: [number, number] initialXDomain?: [number, number] initialXDomains?: Record + xDomainStrategy?: WaveformXDomainStrategy fixedYDomain?: [number, number] fixedYDomains?: Record yDomains?: Record @@ -57,6 +60,21 @@ export interface BuildTrackLayoutsOptions { showCompactEmptyTracks: boolean } +function resolveIndependentXDomain( + track: DisplayTrack, + seriesId: string, + options: BuildTrackLayoutsOptions, +): [number, number] { + const strategy = options.xDomainStrategy ?? { type: 'data' } + const explicitDomain = + options.initialXDomains?.[track.id] ?? + options.initialXDomains?.[seriesId] ?? + options.initialXDomain + return explicitDomain + ? applyXDomainStrategy(explicitDomain, strategy, true) + : applyXDomainStrategy(track.xDomain, strategy) +} + export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayout[] { const visibleCells = options.cells.map((cell) => ({ ...cell, hasSeries: Boolean(cell.series) })) const bottomCells = getBottomRowCellIndexes(visibleCells, options.grid.columnCount) @@ -88,13 +106,7 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou const series = displayTrack.visibleSeries[0] ?? displayTrack.series[0] ?? emptySeries const baseXScale = options.displayMode === 'independent' - ? scaleLinear( - options.initialXDomains?.[displayTrack.id] ?? - options.initialXDomains?.[series.id] ?? - options.initialXDomain ?? - displayTrack.xDomain, - [0, cell.width], - ) + ? scaleLinear(resolveIndependentXDomain(displayTrack, series.id, options), [0, cell.width]) : scaleLinear(options.sharedZoomDomain, [0, cell.width]) const transform = options.displayMode === 'independent' diff --git a/src/components/core/useWaveformChartLifecycle.ts b/src/components/core/useWaveformChartLifecycle.ts index 821e533..2ea258f 100644 --- a/src/components/core/useWaveformChartLifecycle.ts +++ b/src/components/core/useWaveformChartLifecycle.ts @@ -204,6 +204,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) { () => props.minVisiblePoints, () => props.initialXDomain, () => props.initialXDomains, + () => props.xDomainStrategy, () => props.displayMode, () => chartTracks.value.length, currentPage, diff --git a/src/components/core/useWaveformLayout.ts b/src/components/core/useWaveformLayout.ts index 4874f0b..c7b7298 100644 --- a/src/components/core/useWaveformLayout.ts +++ b/src/components/core/useWaveformLayout.ts @@ -34,6 +34,7 @@ import { import type { DisplaySeries, DisplayTrack, TrackLayout } from './types' import type { PreparedWaveformSeries } from './useWaveformData' import type { ResolvedWaveformChartProps } from './waveformChartTypes' +import { applyXDomainStrategy } from './xDomain' import type { useWaveformAnnotationInteraction } from '../annotation' interface LayoutContext { @@ -224,9 +225,13 @@ export function useWaveformLayout(context: LayoutContext) { Number.isFinite(domain[1]) && domain[0] !== domain[1] ) { - return domain[0] < domain[1] ? domain : [domain[1], domain[0]] + return applyXDomainStrategy( + domain[0] < domain[1] ? domain : [domain[1], domain[0]], + props.xDomainStrategy, + true, + ) } - return sharedXDomain.value + return applyXDomainStrategy(sharedXDomain.value, props.xDomainStrategy) }) const resolveInitialTrackDomain = (track: TrackLayout): [number, number] => { const configuredDomain = @@ -239,11 +244,18 @@ export function useWaveformLayout(context: LayoutContext) { Number.isFinite(configuredDomain[1]) && configuredDomain[0] !== configuredDomain[1] ) { - return configuredDomain[0] < configuredDomain[1] - ? configuredDomain - : [configuredDomain[1], configuredDomain[0]] + return applyXDomainStrategy( + configuredDomain[0] < configuredDomain[1] + ? configuredDomain + : [configuredDomain[1], configuredDomain[0]], + props.xDomainStrategy, + true, + ) } - return paddedDomain(track.seriesList.flatMap((series) => series.xDomain)) + return applyXDomainStrategy( + paddedDomain(track.seriesList.flatMap((series) => series.xDomain)), + props.xDomainStrategy, + ) } const sharedZoomDomain = computed( () => @@ -272,6 +284,7 @@ export function useWaveformLayout(context: LayoutContext) { sharedZoomDomain: sharedZoomDomain.value, initialXDomain: props.initialXDomain ? initialXDomain.value : undefined, initialXDomains: props.initialXDomains, + xDomainStrategy: props.xDomainStrategy, fixedYDomain: props.yDomain, fixedYDomains: props.yDomains, yDomains: diff --git a/src/components/core/waveformChartTypes.ts b/src/components/core/waveformChartTypes.ts index 62fb436..0d592e4 100644 --- a/src/components/core/waveformChartTypes.ts +++ b/src/components/core/waveformChartTypes.ts @@ -11,6 +11,7 @@ import type { WaveformPoint, WaveformRenderingOptions, WaveformTitleOptions, + WaveformXDomainStrategy, WaveformZeroLineOptions, WaveformZoomEndPayload, WaveformZoomResetPayload, @@ -33,6 +34,7 @@ export interface WaveformChartProps { minVisiblePoints?: number initialXDomain?: [number, number] initialXDomains?: Record + xDomainStrategy?: WaveformXDomainStrategy yDomain?: [number, number] yDomains?: Record timeUnit?: 's' | 'ms' @@ -63,6 +65,7 @@ type DefaultedProp = | 'zoomable' | 'pannable' | 'minVisiblePoints' + | 'xDomainStrategy' | 'timeUnit' | 'annotations' | 'annotationsVisible' diff --git a/src/components/core/xDomain.test.ts b/src/components/core/xDomain.test.ts new file mode 100644 index 0000000..23def38 --- /dev/null +++ b/src/components/core/xDomain.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest' + +import { applyXDomainStrategy } from './xDomain' + +describe('applyXDomainStrategy', () => { + it('keeps the exact data domain by default', () => { + expect(applyXDomainStrategy([0, 4999.999], { type: 'data' })).toEqual([0, 4999.999]) + }) + + it('expands both bounds to stable nice values', () => { + expect(applyXDomainStrategy([123, 456], { type: 'nice' })).toEqual([100, 500]) + }) + + it('can expand only the end bound', () => { + expect(applyXDomainStrategy([123, 456], { type: 'nice', bounds: 'end' })).toEqual([123, 500]) + }) + + it('keeps explicit domains exact unless they are included', () => { + expect(applyXDomainStrategy([123, 456], { type: 'nice' }, true)).toEqual([123, 456]) + expect(applyXDomainStrategy([123, 456], { type: 'nice', includeExplicit: true }, true)).toEqual( + [100, 500], + ) + }) + + it('falls back to the default tick count for invalid values', () => { + expect(applyXDomainStrategy([0, 4999.999], { type: 'nice', tickCount: 0 })).toEqual([0, 5000]) + }) +}) diff --git a/src/components/core/xDomain.ts b/src/components/core/xDomain.ts new file mode 100644 index 0000000..b39c4c6 --- /dev/null +++ b/src/components/core/xDomain.ts @@ -0,0 +1,25 @@ +import { scaleLinear } from 'd3' + +import type { WaveformXDomainStrategy } from '../../types' + +const DEFAULT_NICE_TICK_COUNT = 10 + +function resolveTickCount(value: number | undefined): number { + if (!Number.isFinite(value) || (value ?? 0) < 1) return DEFAULT_NICE_TICK_COUNT + return Math.max(1, Math.trunc(value as number)) +} + +/** Derives a viewport domain without changing any source coordinates. */ +export function applyXDomainStrategy( + domain: [number, number], + strategy: WaveformXDomainStrategy, + explicit = false, +): [number, number] { + if (strategy.type !== 'nice' || (explicit && !strategy.includeExplicit)) return [...domain] + + const niceDomain = scaleLinear() + .domain(domain) + .nice(resolveTickCount(strategy.tickCount)) + .domain() as [number, number] + return strategy.bounds === 'end' ? [domain[0], niceDomain[1]] : niceDomain +} diff --git a/src/components/data/types.ts b/src/components/data/types.ts index d0b7d5b..c8db671 100644 --- a/src/components/data/types.ts +++ b/src/components/data/types.ts @@ -9,6 +9,7 @@ export type { WaveformDisplayMode, WaveformOverlayMode, WaveformInteractionMode, + WaveformXDomainStrategy, WaveformZoomEndPayload, WaveformZoomResetPayload, WaveformAnnotationStyle, diff --git a/src/components/waveform.ts b/src/components/waveform.ts index 8b34811..4c4c877 100644 --- a/src/components/waveform.ts +++ b/src/components/waveform.ts @@ -9,6 +9,7 @@ export type { WaveformDisplayMode, WaveformOverlayMode, WaveformInteractionMode, + WaveformXDomainStrategy, WaveformAnnotationStyle, WaveformAnnotation, WaveformRenderingOptions, diff --git a/src/components/waveformChartCases/viewportReset.test.ts b/src/components/waveformChartCases/viewportReset.test.ts index 08039c4..76513dd 100644 --- a/src/components/waveformChartCases/viewportReset.test.ts +++ b/src/components/waveformChartCases/viewportReset.test.ts @@ -5,6 +5,13 @@ import { flushAnimationFrames } from '../../test/setup' import { gridSeries, mountSizedChart } from '../../test/waveformChart' describe('WaveformChart viewport reset', () => { + const niceExplicitStrategy = { + type: 'nice' as const, + bounds: 'end' as const, + tickCount: 10, + includeExplicit: true, + } + it('resets a shared viewport on double-click and emits a global payload', async () => { const wrapper = await mountSizedChart( { @@ -111,4 +118,32 @@ describe('WaveformChart viewport reset', () => { expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0') expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1000') }) + + it('resets shared and independent explicit domains to their included nice bounds', async () => { + const shared = await mountSizedChart( + { + kind: 'points', + points: [ + { x: 0, y: 0 }, + { x: 4.999999, y: 1 }, + ], + }, + { initialXDomain: [0, 4.999999], xDomainStrategy: niceExplicitStrategy }, + ) + const independent = await mountSizedChart(gridSeries(2), { + displayMode: 'independent', + initialXDomains: { 'channel-0': [0, 4.999999] }, + xDomainStrategy: niceExplicitStrategy, + }) + + ;(shared.vm as unknown as { resetViewport: () => void }).resetViewport() + ;(independent.vm as unknown as { resetViewport: () => void }).resetViewport() + await flushPromises() + + expect(shared.get('.waveform-chart__axis-endpoint--start').text()).toBe('0') + expect(shared.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000') + const firstTrack = independent.findAll('.waveform-chart__track')[0] + expect(firstTrack.get('.waveform-chart__axis-endpoint--start').text()).toBe('0') + expect(firstTrack.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000') + }) }) diff --git a/src/components/waveformChartCases/xDomainStrategy.test.ts b/src/components/waveformChartCases/xDomainStrategy.test.ts new file mode 100644 index 0000000..6edec71 --- /dev/null +++ b/src/components/waveformChartCases/xDomainStrategy.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest' + +import { gridSeries, mountSizedChart } from '../../test/waveformChart' + +describe('WaveformChart x domain strategy', () => { + it('expands automatic x domains without changing source seconds', async () => { + const data = { + kind: 'points' as const, + points: [ + { x: 0, y: 0 }, + { x: 4.999999, y: 1 }, + ], + } + const wrapper = await mountSizedChart(data, { + timeUnit: 'ms', + xDomainStrategy: { type: 'nice', bounds: 'end' }, + }) + + expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0') + expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000') + expect(data.points[1]?.x).toBe(4.999999) + }) + + it('keeps explicit initial x domains exact by default', async () => { + const wrapper = await mountSizedChart( + { + kind: 'points', + points: [ + { x: 123, y: 0 }, + { x: 456, y: 1 }, + ], + }, + { + timeUnit: 's', + initialXDomain: [120, 460], + xDomainStrategy: { type: 'nice' }, + }, + ) + + expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('120') + expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('460') + }) + + it('keeps explicit per-track initial x domains exact by default', async () => { + const wrapper = await mountSizedChart(gridSeries(1), { + displayMode: 'independent', + initialXDomains: { 'channel-0': [0, 4.999999] }, + xDomainStrategy: { type: 'nice', bounds: 'end' }, + }) + + expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0') + expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('4999.999') + }) + + it('includes an explicit shared initial x domain when requested', async () => { + const wrapper = await mountSizedChart( + { + kind: 'points', + points: [ + { x: 0, y: 0 }, + { x: 4.999999, y: 1 }, + ], + }, + { + initialXDomain: [0, 4.999999], + xDomainStrategy: { + type: 'nice', + bounds: 'end', + tickCount: 10, + includeExplicit: true, + }, + }, + ) + + expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0') + expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000') + }) + + it('includes explicit per-track initial x domains in independent mode', async () => { + const wrapper = await mountSizedChart(gridSeries(2), { + displayMode: 'independent', + grid: { rowCount: 1, columnCount: 2 }, + initialXDomains: { + 'channel-0': [0, 4.999999], + 'channel-1': [10, 14.999999], + }, + xDomainStrategy: { type: 'nice', bounds: 'end', tickCount: 10, includeExplicit: true }, + }) + + const tracks = wrapper.findAll('.waveform-chart__track') + expect(tracks[0]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('0') + expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5000') + expect(tracks[1]?.get('.waveform-chart__axis-endpoint--start').text()).toBe('10000') + expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('15000') + }) +}) diff --git a/src/index.ts b/src/index.ts index bd9fff6..b5e09a4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ export type { WaveformDisplayMode, WaveformOverlayMode, WaveformInteractionMode, + WaveformXDomainStrategy, WaveformZoomEndPayload, WaveformZoomResetPayload, WaveformAnnotationStyle, diff --git a/src/types/chart.ts b/src/types/chart.ts index c660a99..c0c170f 100644 --- a/src/types/chart.ts +++ b/src/types/chart.ts @@ -26,6 +26,17 @@ export type WaveformOverlayMode = 'single-axis' | 'multi-axis' /** 标注工具模式 */ export type WaveformInteractionMode = 'zoom' | 'annotation' +/** Controls how the initial X viewport is derived when no explicit domain is configured. */ +export interface WaveformXDomainStrategy { + type: 'data' | 'nice' + /** Selects which bounds are expanded when type is `nice`. Defaults to `both`. */ + bounds?: 'both' | 'end' + /** Stable tick count used to calculate nice bounds. Defaults to 10. */ + tickCount?: number + /** Applies the strategy to explicit initial domains. Defaults to false. */ + includeExplicit?: boolean +} + /** Describes the X-axis viewport after a zoom gesture completes. */ export interface WaveformZoomEndPayload { start: number diff --git a/src/types/index.ts b/src/types/index.ts index e376a8c..afecd66 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -8,6 +8,7 @@ export type { WaveformDisplayMode, WaveformOverlayMode, WaveformInteractionMode, + WaveformXDomainStrategy, WaveformZoomEndPayload, WaveformZoomResetPayload, WaveformAnnotationStyle,