feat(chart): support explicit nice x domains
Some checks failed
Package component / package (push) Failing after 3s

This commit is contained in:
李启源
2026-08-13 21:51:49 +08:00
parent e940e0af1d
commit 81f36bde71
16 changed files with 265 additions and 15 deletions

View File

@@ -16,6 +16,7 @@ const props = withDefaults(defineProps<WaveformChartProps>(), {
zoomable: true,
pannable: false,
minVisiblePoints: 0,
xDomainStrategy: () => ({ type: 'data' }),
timeUnit: 'ms',
frameNumber: undefined,
annotations: () => [],

View File

@@ -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<string, [number, number]>
xDomainStrategy?: WaveformXDomainStrategy
fixedYDomain?: [number, number]
fixedYDomains?: Record<string, [number, number]>
yDomains?: Record<string, [number, number]>
@@ -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'

View File

@@ -204,6 +204,7 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
() => props.minVisiblePoints,
() => props.initialXDomain,
() => props.initialXDomains,
() => props.xDomainStrategy,
() => props.displayMode,
() => chartTracks.value.length,
currentPage,

View File

@@ -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:

View File

@@ -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<string, [number, number]>
xDomainStrategy?: WaveformXDomainStrategy
yDomain?: [number, number]
yDomains?: Record<string, [number, number]>
timeUnit?: 's' | 'ms'
@@ -63,6 +65,7 @@ type DefaultedProp =
| 'zoomable'
| 'pannable'
| 'minVisiblePoints'
| 'xDomainStrategy'
| 'timeUnit'
| 'annotations'
| 'annotationsVisible'

View File

@@ -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])
})
})

View File

@@ -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
}

View File

@@ -9,6 +9,7 @@ export type {
WaveformDisplayMode,
WaveformOverlayMode,
WaveformInteractionMode,
WaveformXDomainStrategy,
WaveformZoomEndPayload,
WaveformZoomResetPayload,
WaveformAnnotationStyle,

View File

@@ -9,6 +9,7 @@ export type {
WaveformDisplayMode,
WaveformOverlayMode,
WaveformInteractionMode,
WaveformXDomainStrategy,
WaveformAnnotationStyle,
WaveformAnnotation,
WaveformRenderingOptions,

View File

@@ -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')
})
})

View File

@@ -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')
})
})