feat(chart): support fixed y-axis domains
All checks were successful
Package component / package (push) Successful in 9m22s
All checks were successful
Package component / package (push) Successful in 9m22s
This commit is contained in:
14
src/DemoRouterApp.vue
Normal file
14
src/DemoRouterApp.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<template>
|
||||
<div class="demo-shell">
|
||||
<header class="demo-shell__header">
|
||||
<RouterLink class="demo-shell__brand" to="/">Waveform Analysis</RouterLink>
|
||||
<nav class="demo-shell__nav" aria-label="示例导航">
|
||||
<RouterLink to="/">综合示例</RouterLink>
|
||||
<RouterLink to="/fixed-y-domain">固定振幅范围</RouterLink>
|
||||
</nav>
|
||||
</header>
|
||||
<div class="demo-shell__content">
|
||||
<RouterView />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
116
src/components/core/fixedYDomainLayout.test.ts
Normal file
116
src/components/core/fixedYDomainLayout.test.ts
Normal file
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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<string, WaveformYDomain>,
|
||||
): 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<string, WaveformYDomain>,
|
||||
): { 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
|
||||
|
||||
@@ -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<string, [number, number]>
|
||||
fixedYDomain?: [number, number]
|
||||
fixedYDomains?: Record<string, [number, number]>
|
||||
yDomains?: Record<string, [number, number]>
|
||||
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 +
|
||||
|
||||
@@ -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<AnnotationSeriesCandidate[]>([])
|
||||
const hoverThrottle = useAnimationFrameThrottle()
|
||||
|
||||
watch(
|
||||
[() => props.yDomain, () => props.yDomains],
|
||||
() => {
|
||||
sharedYDomains.value = {}
|
||||
independentYDomains.value = {}
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
const selection = ref<ViewportSelectionState | null>(null)
|
||||
const spacePressed = ref(false)
|
||||
const pointerInsideChart = ref(false)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -31,6 +31,8 @@ export interface WaveformChartProps {
|
||||
minVisiblePoints?: number
|
||||
initialXDomain?: [number, number]
|
||||
initialXDomains?: Record<string, [number, number]>
|
||||
yDomain?: [number, number]
|
||||
yDomains?: Record<string, [number, number]>
|
||||
timeUnit?: 's' | 'ms'
|
||||
frameNumber?: string | number
|
||||
frameStyle?: WaveformFrameStyle
|
||||
|
||||
56
src/components/core/yDomain.test.ts
Normal file
56
src/components/core/yDomain.test.ts
Normal file
@@ -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])
|
||||
})
|
||||
})
|
||||
50
src/components/core/yDomain.ts
Normal file
50
src/components/core/yDomain.ts
Normal file
@@ -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<DisplayTrack, 'id'>,
|
||||
yDomains?: Record<string, WaveformYDomain>,
|
||||
): WaveformYDomain | undefined {
|
||||
return normalizeYDomain(yDomains?.[track.id])
|
||||
}
|
||||
|
||||
export function resolveSeriesFixedYDomain(
|
||||
series: Pick<DisplaySeries, 'id'>,
|
||||
yDomain?: WaveformYDomain,
|
||||
yDomains?: Record<string, WaveformYDomain>,
|
||||
): WaveformYDomain | undefined {
|
||||
return normalizeYDomain(yDomains?.[series.id]) ?? normalizeYDomain(yDomain)
|
||||
}
|
||||
|
||||
export function hasFixedYDomainForTrack(
|
||||
track: Pick<DisplayTrack, 'id' | 'visibleSeries'>,
|
||||
yDomain?: WaveformYDomain,
|
||||
yDomains?: Record<string, WaveformYDomain>,
|
||||
): 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])),
|
||||
]
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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',
|
||||
|
||||
197
src/demoRoutes.css
Normal file
197
src/demoRoutes.css
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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')
|
||||
|
||||
21
src/router.ts
Normal file
21
src/router.ts
Normal file
@@ -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,
|
||||
})
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
44
src/views/FixedYDomainDemo.test.ts
Normal file
44
src/views/FixedYDomainDemo.test.ts
Normal file
@@ -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()
|
||||
})
|
||||
})
|
||||
149
src/views/FixedYDomainDemo.vue
Normal file
149
src/views/FixedYDomainDemo.vue
Normal file
@@ -0,0 +1,149 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { InputNumber, RadioButton, RadioGroup } from 'ant-design-vue'
|
||||
|
||||
import { WaveformChart, type WaveformData } from '../components'
|
||||
|
||||
type RangeMode = 'auto' | 'global' | 'channel'
|
||||
|
||||
const rangeMode = ref<RangeMode>('global')
|
||||
const globalMinimum = ref(-80)
|
||||
const globalMaximum = ref(80)
|
||||
const voltageMinimum = ref(-65)
|
||||
const voltageMaximum = ref(65)
|
||||
const currentMinimum = ref(-260)
|
||||
const currentMaximum = ref(260)
|
||||
|
||||
const chartData: WaveformData = {
|
||||
kind: 'series',
|
||||
series: [
|
||||
{
|
||||
id: 'voltage',
|
||||
name: '电压',
|
||||
unit: 'V',
|
||||
color: '#1677ff',
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: Array.from({ length: 800 }, (_, index) => {
|
||||
const x = index / 80
|
||||
const pulse = index % 240 >= 112 && index % 240 <= 122 ? 58 : 0
|
||||
return { x, y: 48 * Math.sin(x * Math.PI * 1.5) + pulse }
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'current',
|
||||
name: '电流',
|
||||
unit: 'mA',
|
||||
color: '#d4380d',
|
||||
data: {
|
||||
kind: 'points',
|
||||
points: Array.from({ length: 800 }, (_, index) => {
|
||||
const x = index / 80
|
||||
const pulse = index % 300 >= 184 && index % 300 <= 192 ? -210 : 0
|
||||
return { x, y: 185 * Math.cos(x * Math.PI) + pulse }
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
function orderedDomain(minimum: number, maximum: number): [number, number] | undefined {
|
||||
if (!Number.isFinite(minimum) || !Number.isFinite(maximum) || minimum === maximum) {
|
||||
return undefined
|
||||
}
|
||||
return minimum < maximum ? [minimum, maximum] : [maximum, minimum]
|
||||
}
|
||||
|
||||
const yDomain = computed<[number, number] | undefined>(() =>
|
||||
rangeMode.value === 'global'
|
||||
? orderedDomain(globalMinimum.value, globalMaximum.value)
|
||||
: undefined,
|
||||
)
|
||||
const yDomains = computed<Record<string, [number, number]> | undefined>(() => {
|
||||
if (rangeMode.value !== 'channel') return undefined
|
||||
const voltage = orderedDomain(voltageMinimum.value, voltageMaximum.value)
|
||||
const current = orderedDomain(currentMinimum.value, currentMaximum.value)
|
||||
return {
|
||||
...(voltage ? { voltage } : {}),
|
||||
...(current ? { current } : {}),
|
||||
}
|
||||
})
|
||||
const rangeSummary = computed(() => {
|
||||
if (rangeMode.value === 'auto') return '自动计算'
|
||||
if (rangeMode.value === 'global') {
|
||||
return yDomain.value ? `${yDomain.value[0]} ~ ${yDomain.value[1]}` : '自动计算'
|
||||
}
|
||||
const voltage = yDomains.value?.voltage
|
||||
const current = yDomains.value?.current
|
||||
return `电压 ${voltage?.[0] ?? '-'} ~ ${voltage?.[1] ?? '-'} V · 电流 ${
|
||||
current?.[0] ?? '-'
|
||||
} ~ ${current?.[1] ?? '-'} mA`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="fixed-domain-demo">
|
||||
<header class="fixed-domain-demo__toolbar">
|
||||
<div>
|
||||
<h1>固定振幅范围</h1>
|
||||
<p>当前范围:{{ rangeSummary }}</p>
|
||||
</div>
|
||||
<RadioGroup v-model:value="rangeMode" button-style="solid" aria-label="Y 轴范围模式">
|
||||
<RadioButton value="auto">自动</RadioButton>
|
||||
<RadioButton value="global">全局固定</RadioButton>
|
||||
<RadioButton value="channel">按通道固定</RadioButton>
|
||||
</RadioGroup>
|
||||
</header>
|
||||
|
||||
<section class="fixed-domain-demo__range-controls">
|
||||
<template v-if="rangeMode === 'global'">
|
||||
<label>
|
||||
<span>下限</span>
|
||||
<InputNumber v-model:value="globalMinimum" aria-label="全局振幅下限" />
|
||||
</label>
|
||||
<label>
|
||||
<span>上限</span>
|
||||
<InputNumber v-model:value="globalMaximum" aria-label="全局振幅上限" />
|
||||
</label>
|
||||
</template>
|
||||
<template v-else-if="rangeMode === 'channel'">
|
||||
<strong>电压</strong>
|
||||
<label>
|
||||
<span>下限</span>
|
||||
<InputNumber v-model:value="voltageMinimum" aria-label="电压振幅下限" />
|
||||
</label>
|
||||
<label>
|
||||
<span>上限</span>
|
||||
<InputNumber v-model:value="voltageMaximum" aria-label="电压振幅上限" />
|
||||
</label>
|
||||
<strong>电流</strong>
|
||||
<label>
|
||||
<span>下限</span>
|
||||
<InputNumber v-model:value="currentMinimum" aria-label="电流振幅下限" />
|
||||
</label>
|
||||
<label>
|
||||
<span>上限</span>
|
||||
<InputNumber v-model:value="currentMaximum" aria-label="电流振幅上限" />
|
||||
</label>
|
||||
</template>
|
||||
<span v-else class="fixed-domain-demo__auto-state">根据可见数据自动计算</span>
|
||||
</section>
|
||||
|
||||
<section class="fixed-domain-demo__chart">
|
||||
<WaveformChart
|
||||
:data="chartData"
|
||||
:y-domain="yDomain"
|
||||
:y-domains="yDomains"
|
||||
display-mode="independent"
|
||||
:grid="{ rowCount: 2, columnCount: 1, showPagination: false }"
|
||||
:title="{ visible: true, text: '振幅上下限示例', align: 'left' }"
|
||||
:zero-line="{ visible: true, color: '#98a2b3', width: 1, dash: '5 4' }"
|
||||
:legend="{ position: 'top-right', backgroundColor: 'rgba(255,255,255,0.8)' }"
|
||||
x-label="时间(s)"
|
||||
time-unit="s"
|
||||
:show-tooltip="true"
|
||||
/>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
Reference in New Issue
Block a user