2 Commits

Author SHA1 Message Date
李启源
5c71c28429 feat(视口): 支持恢复受控缩放范围
All checks were successful
Package component / package (push) Successful in 4m38s
2026-08-18 09:08:36 +08:00
李启源
7a6a7e5c26 feat(data): 扩展示例波形数据与测试覆盖
All checks were successful
Package component / package (push) Successful in 4m26s
新增示例谐波对比数据,完善图例、多轴和演示测试,并同步文档说明。
2026-08-17 14:34:15 +08:00
15 changed files with 371 additions and 79 deletions

View File

@@ -97,7 +97,7 @@ const data = ref<WaveformData>({
### Props
| Prop | 类型 | 默认值 | 说明 |
| --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | ---------------------------------- |
| --------------------------------- | ------------------------------------------- | ------------------------------------------------------- | --------------------------------------------- |
| `data` | `WaveformData` | 必填 | 波形数据 |
| `displayMode` | `'independent' \| 'separated' \| 'compact'` | `'independent'` | 图框布局 |
| `overlayMode` | `'single-axis' \| 'multi-axis'` | `'single-axis'` | 叠加曲线的 Y 轴模式 |
@@ -253,6 +253,20 @@ X 轴且包含多个轨道时使用按稳定 track ID 索引的 `yRanges`。平
重置组件内部缩放并触发 `zoom-reset`;调用方应在事件中取消区间请求并恢复首次完整数据。
外部重置按钮也可以通过模板引用调用组件公开的 `resetViewport()` 方法,然后执行相同的数据恢复逻辑。
宿主在替换局部数据后如果需要恢复之前保存的 X 视口,可以调用公开的
`setViewportDomain(domain, trackIndex?)`。传入范围使用原始秒坐标,组件会按当前数据边界、
`xDomainStrategy``minZoomSpan``minVisiblePoints` 重新约束;无效范围会被忽略。共享
X 轴模式直接传入一个范围,独立模式可以按实际 `trackIndex` 分别设置(省略 `trackIndex`
时应用到当前所有图框):
```ts
chartRef.value?.setViewportDomain(previousDomain)
chartRef.value?.setViewportDomain(trackDomain, trackIndex)
```
建议在 `data` 引用更新后调用该方法。组件也会在数据引用变更时重投影当前内部视口,避免新
数据边界使旧 transform 失效;`resetViewport(trackIndex?)` 的既有行为保持不变。
没有显式配置初始范围时,可以通过 `xDomainStrategy` 将数据范围扩展为便于阅读的视口端点。
默认的 `{ type: 'data' }` 保持数据最小值和最大值不变;`type: 'nice'` 使用固定刻度数量计算
易读边界且只扩展视口不修改原始点位、tooltip、标注或缩放事件值
@@ -415,7 +429,8 @@ Demo 左侧控制面板提供标题实时预览,可配置标题名称、显隐
### 图例与曲线显隐
`legend.backgroundColor` 设置多曲线图例的背景颜色。该字段接受任意有效 CSS 颜色值,
每个图框独立管理自己的图例:图框内有两条或更多曲线时显示图例,只有一条曲线时不显示。
`legend.backgroundColor` 设置图例的背景颜色。该字段接受任意有效 CSS 颜色值,
可通过 `rgba(...)``hsla(...)` 中的 alpha 通道调整透明度:
```vue

View File

@@ -1,6 +1,6 @@
{
"name": "waveform-analysis",
"version": "0.1.37",
"version": "0.1.39",
"main": "./dist/index.cjs",
"module": "./dist/index.js",
"types": "./dist/types/index.d.ts",

View File

@@ -235,7 +235,7 @@ describe('App workspace layout', { timeout: 20_000 }, () => {
tracks.map((track) =>
track.findAll('.waveform-chart__series').map((item) => item.attributes('data-series-name')),
),
).toEqual([['正弦基波'], ['谐波扰动'], ['阻尼振荡'], ['阶跃响应']])
).toEqual([['正弦基波'], ['谐波扰动', '谐波对比'], ['阻尼振荡'], ['阶跃响应']])
expect(
tracks
.slice(1)
@@ -253,6 +253,7 @@ describe('App workspace layout', { timeout: 20_000 }, () => {
expect(simulatedSeries.map((item) => item.name)).toEqual([
'正弦基波',
'谐波扰动',
'谐波对比',
'阻尼振荡',
'阶跃响应',
'脉冲响应',
@@ -321,7 +322,6 @@ describe('App workspace layout', { timeout: 20_000 }, () => {
const styleSelect = frameControls.findAllComponents(Select)[0]
expect(colorPickers).toHaveLength(2)
const initialFrames = wrapper.findAll('.waveform-chart__plot-frame')
expect(initialFrames.length > 1 && initialFrames.every((frame) => frame.attributes('stroke-width') === '2')).toBe(true)

View File

@@ -36,6 +36,7 @@ const controller = useWaveformChartController(props as ResolvedWaveformChartProp
defineExpose({
resetViewport: controller.resetViewport,
setViewportDomain: controller.setViewportDomain,
})
</script>

View File

@@ -259,6 +259,9 @@ export function useWaveformChartController(
trackLayouts,
innerWidth,
innerHeight,
sharedZoomDomain,
initialXDomain,
sharedTransform,
activeInteractionMode,
hiddenSeriesIdSet,
internalHiddenSeriesIds,

View File

@@ -39,6 +39,9 @@ interface LifecycleContext {
trackLayouts: ComputedRef<TrackLayout[]>
innerWidth: ComputedRef<number>
innerHeight: ComputedRef<number>
sharedZoomDomain: ComputedRef<[number, number]>
initialXDomain: ComputedRef<[number, number]>
sharedTransform: ShallowRef<ZoomTransform>
activeInteractionMode: ComputedRef<string | undefined>
hiddenSeriesIdSet: ComputedRef<Set<string>>
internalHiddenSeriesIds: Ref<Set<string>>
@@ -93,6 +96,9 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
trackLayouts,
innerWidth,
innerHeight,
sharedZoomDomain,
initialXDomain,
sharedTransform,
activeInteractionMode,
hiddenSeriesIdSet,
internalHiddenSeriesIds,
@@ -144,25 +150,24 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
emit('page-change', nextPage, pageCount.value)
}
let pendingSharedXDomain: [number, number] | undefined
let pendingIndependentXDomains: Array<[number, number] | undefined> | undefined
function handleBeforeDataReferenceChange() {
if (props.displayMode !== 'independent') return
if (props.displayMode === 'independent') {
pendingSharedXDomain = undefined
pendingIndependentXDomains = trackLayouts.value.map((track) => {
const configuredDomain =
props.initialXDomains?.[track.series.trackId ?? track.series.id] ??
props.initialXDomains?.[track.series.id] ??
props.initialXDomain
if (
!configuredDomain ||
!Number.isFinite(configuredDomain[0]) ||
!Number.isFinite(configuredDomain[1]) ||
configuredDomain[0] === configuredDomain[1]
) {
return undefined
}
return track.xScale.domain() as [number, number]
const current = track.xScale.domain() as [number, number]
const boundary = resolveInitialTrackDomain(track)
return current[1] - current[0] < boundary[1] - boundary[0] - 1e-12 ? current : undefined
})
return
}
pendingIndependentXDomains = undefined
const current = sharedZoomDomain.value
const boundary = initialXDomain.value
pendingSharedXDomain =
current[1] - current[0] < boundary[1] - boundary[0] - 1e-12 ? [...current] : undefined
}
function handleDataReferenceChange() {
@@ -186,10 +191,15 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
})
independentTransforms.value = nextTransforms
pendingIndependentXDomains = undefined
void nextTick(configureZoom)
return
} else if (props.displayMode !== 'independent' && pendingSharedXDomain) {
const boundary = initialXDomain.value
const groups = trackLayouts.value
.filter((track) => track.hasVisibleSeries)
.map((track) => track.seriesList)
const domain = constrainZoomDomain(pendingSharedXDomain, boundary, groups, props)
sharedTransform.value = transformForDomain(domain, boundary, innerWidth.value)
pendingSharedXDomain = undefined
}
pendingIndependentXDomains = undefined
configureZoom()
})
}

View File

@@ -11,6 +11,7 @@ import type {
import type { AnnotationSeriesCandidate } from '../annotation'
import { tryReleasePointerCapture } from './pointerCapture'
import { transitionViewportInteraction } from './viewportInteractionState'
import { createViewportDomainSetter } from './viewportDomain'
import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
interface ViewportContext {
props: ResolvedWaveformChartProps
@@ -344,6 +345,20 @@ export function useWaveformViewport(context: ViewportContext) {
editorSeriesOptions.value = []
void nextTick(configureZoom)
}
const setViewportDomain = createViewportDomainSetter({
props,
trackLayouts,
initialXDomain,
resolveInitialTrackDomain,
innerWidth,
sharedTransform,
independentTransforms,
cancelPendingZoom,
cancelViewportDrag,
clearHover,
editorSeriesOptions,
configureZoom,
})
const requestViewportReset = (event: MouseEvent) => {
if (isPresentationMode.value || !props.zoomable || !isZoomMode.value) return
if (props.displayMode === 'independent') {
@@ -372,6 +387,7 @@ export function useWaveformViewport(context: ViewportContext) {
finishViewportDrag,
cancelViewportDrag,
resetViewport,
setViewportDomain,
requestViewportReset,
}
}

View File

@@ -0,0 +1,60 @@
import { nextTick, type ComputedRef, type Ref, type ShallowRef } from 'vue'
import type { ZoomTransform } from 'd3'
import type { TrackLayout } from '../core/types'
import type { ResolvedWaveformChartProps } from '../core/waveformChartTypes'
import type { AnnotationSeriesCandidate } from '../annotation'
import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
interface ViewportDomainContext {
props: ResolvedWaveformChartProps
trackLayouts: ComputedRef<TrackLayout[]>
initialXDomain: ComputedRef<[number, number]>
resolveInitialTrackDomain: (track: TrackLayout) => [number, number]
innerWidth: ComputedRef<number>
sharedTransform: ShallowRef<ZoomTransform>
independentTransforms: ShallowRef<ZoomTransform[]>
cancelPendingZoom: () => void
cancelViewportDrag: () => void
clearHover: () => void
editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
configureZoom: () => void
}
export function createViewportDomainSetter(context: ViewportDomainContext) {
return (domain: [number, number], trackIndex?: number) => {
if (!Number.isFinite(domain[0]) || !Number.isFinite(domain[1]) || domain[0] === domain[1])
return
context.cancelPendingZoom()
context.cancelViewportDrag()
if (context.props.displayMode === 'independent') {
const indexes =
trackIndex === undefined
? context.trackLayouts.value.map((track) => track.index)
: [trackIndex]
const nextTransforms = [...context.independentTransforms.value]
indexes.forEach((index) => {
const track = context.trackLayouts.value.find((item) => item.index === index)
if (!track) return
const boundary = context.resolveInitialTrackDomain(track)
const constrained = constrainZoomDomain(domain, boundary, [track.seriesList], context.props)
nextTransforms[index] = transformForDomain(constrained, boundary, track.width)
})
context.independentTransforms.value = nextTransforms
} else {
const boundary = context.initialXDomain.value
const groups = context.trackLayouts.value
.filter((track) => track.hasVisibleSeries)
.map((track) => track.seriesList)
const constrained = constrainZoomDomain(domain, boundary, groups, context.props)
context.sharedTransform.value = transformForDomain(
constrained,
boundary,
context.innerWidth.value,
)
}
context.clearHover()
context.editorSeriesOptions.value = []
void nextTick(context.configureZoom)
}
}

View File

@@ -16,13 +16,30 @@ function annotationEditorExists() {
}
describe('WaveformChart', () => {
it('shows legends when a track contains at least two series', async () => {
const oneSeries = visibilitySeries()
if (oneSeries.kind === 'series') oneSeries.series = oneSeries.series.slice(0, 1)
const withoutLegend = await mountSizedChart(oneSeries, {
grid: { rowCount: 1, columnCount: 1 },
})
expect(withoutLegend.findAll('.waveform-chart__legend')).toHaveLength(0)
const twoSeries = visibilitySeries()
if (twoSeries.kind === 'series') twoSeries.series = twoSeries.series.slice(0, 2)
const withLegend = await mountSizedChart(twoSeries, {
grid: { rowCount: 1, columnCount: 1 },
})
expect(withLegend.findAll('.waveform-chart__legend')).toHaveLength(1)
expect(withLegend.find('.waveform-chart__legend').attributes('data-position')).toBe('top-right')
})
it('resolves legend positions by stable track id across pages', async () => {
const wrapper = await mountSizedChart(
{
kind: 'series',
series: Array.from({ length: 8 }, (_, index) => ({
series: Array.from({ length: 12 }, (_, index) => ({
id: `series-${index}`,
trackId: `frame-${Math.floor(index / 2)}`,
trackId: `frame-${Math.floor(index / 3)}`,
name: `series ${index}`,
data: {
kind: 'points' as const,
@@ -75,9 +92,9 @@ describe('WaveformChart', () => {
const wrapper = await mountSizedChart(
{
kind: 'series',
series: Array.from({ length: 4 }, (_, index) => ({
series: Array.from({ length: 6 }, (_, index) => ({
id: `series-${index}`,
trackId: `frame-${Math.floor(index / 2)}`,
trackId: `frame-${Math.floor(index / 3)}`,
name: `series ${index}`,
data: {
kind: 'points',
@@ -112,7 +129,7 @@ describe('WaveformChart', () => {
})
const items = wrapper.findAll('.waveform-chart__legend-item')
expect(items).toHaveLength(2)
expect(items).toHaveLength(3)
expect(items.every((item) => item.attributes('disabled') !== undefined)).toBe(true)
expect(wrapper.get('.waveform-legend__panel').classes()).not.toContain(
'waveform-legend__panel--interactive',
@@ -128,7 +145,7 @@ describe('WaveformChart', () => {
overlayMode: 'multi-axis',
})
expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(2)
expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(3)
expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true)
const annotationLayer = wrapper.get('.waveform-annotation-layer').element
const legendLayer = wrapper.get('.waveform-chart__legend-layer').element
@@ -143,8 +160,8 @@ describe('WaveformChart', () => {
expect(
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['low'])
expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(1)
).toEqual(['low', 'mid'])
expect(wrapper.findAll('.waveform-chart__axis--y')).toHaveLength(2)
expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(false)
expect(wrapper.findAll('.waveform-chart__legend-item')[1].classes()).toContain('is-hidden')
expect(wrapper.findAll('.waveform-chart__legend-item')[1].attributes('aria-pressed')).toBe(
@@ -170,12 +187,12 @@ describe('WaveformChart', () => {
)
flushAnimationFrames()
await flushPromises()
expect(wrapper.findAll('.waveform-chart__tooltip-series')).toHaveLength(1)
expect(wrapper.findAll('.waveform-chart__tooltip-series')).toHaveLength(2)
expect(wrapper.get('.waveform-chart__tooltip-series').text()).toContain('低量程')
await wrapper.findAll('.waveform-chart__legend-item')[1].trigger('click')
await flushPromises()
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(2)
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(3)
expect(wrapper.find('[data-annotation-id="high-note"]').exists()).toBe(true)
expect(wrapper.emitted('series-visibility-change')?.at(-1)).toEqual([
{ seriesId: 'high', visible: true, hiddenSeriesIds: [] },
@@ -191,20 +208,20 @@ describe('WaveformChart', () => {
expect(
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['low'])
).toEqual(['low', 'mid'])
await wrapper.findAll('.waveform-chart__legend-item')[0].trigger('click')
expect(wrapper.emitted('update:hidden-series-ids')?.at(-1)).toEqual([
['high', 'temporarily-absent', 'low'],
])
expect(
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['low'])
).toEqual(['low', 'mid'])
await wrapper.setProps({ hiddenSeriesIds: ['low'] })
await flushPromises()
expect(
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['high'])
).toEqual(['high', 'mid'])
})
it('retains uncontrolled visibility by stable ID and clears removed IDs', async () => {
@@ -223,7 +240,7 @@ describe('WaveformChart', () => {
await flushPromises()
expect(
wrapper.findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['low'])
).toEqual(['mid', 'low'])
await wrapper.setProps({
data: {
@@ -234,12 +251,12 @@ describe('WaveformChart', () => {
await flushPromises()
await wrapper.setProps({ data: original })
await flushPromises()
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(2)
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(3)
})
it('keeps a recoverable legend and stops chart interaction when every series is hidden', async () => {
const wrapper = await mountSizedChart(visibilitySeries(), {
defaultHiddenSeriesIds: ['low', 'high'],
defaultHiddenSeriesIds: ['low', 'high', 'mid'],
grid: { rowCount: 1, columnCount: 1 },
legend: { interactive: true },
overlayMode: 'multi-axis',
@@ -248,7 +265,7 @@ describe('WaveformChart', () => {
expect(wrapper.findAll('.waveform-chart__line')).toHaveLength(0)
expect(wrapper.findAll('.waveform-chart__axis')).toHaveLength(0)
expect(wrapper.findAll('.waveform-chart__overlay')).toHaveLength(0)
expect(wrapper.findAll('.waveform-chart__legend-item')).toHaveLength(2)
expect(wrapper.findAll('.waveform-chart__legend-item')).toHaveLength(3)
expect(wrapper.get('.waveform-track__no-visible-series').text()).toBe('暂无可见曲线')
await wrapper.findAll('.waveform-chart__legend-item')[0].trigger('click')

View File

@@ -177,6 +177,18 @@ describe('WaveformChart', () => {
],
},
},
{
id: 'reference',
trackId: 'frame-1',
name: 'REF_CH_1',
data: {
kind: 'points',
points: [
{ x: 0, y: 0.25 },
{ x: 1, y: 1.25 },
],
},
},
],
},
{ frameNumber: 1, grid: { rowCount: 2, columnCount: 1 } },
@@ -186,7 +198,7 @@ describe('WaveformChart', () => {
expect(tracks).toHaveLength(2)
expect(
tracks[0].findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['primary', 'comparison'])
).toEqual(['primary', 'comparison', 'reference'])
expect(
tracks[1].findAll('.waveform-chart__line').map((line) => line.attributes('data-series-id')),
).toEqual(['second-frame'])
@@ -203,12 +215,13 @@ describe('WaveformChart', () => {
expect(legend.findAll('.waveform-chart__legend-item').map((item) => item.text())).toEqual([
'BT2_2M',
'TEST_CH_1',
'REF_CH_1',
])
expect(
legend
.findAll('.waveform-legend__swatch')
.map((swatch) => swatch.get('path').attributes('stroke')),
).toEqual(['#0960bd', '#2ca02c'])
).toEqual(['#0960bd', '#2ca02c', '#d62728'])
expect(wrapper.findAll('.waveform-chart__watermark').map((item) => item.text())).toEqual([
'1',
'2',
@@ -232,10 +245,11 @@ describe('WaveformChart', () => {
await flushPromises()
const tooltipSeries = wrapper.findAll('.waveform-chart__tooltip-series')
expect(tooltipSeries).toHaveLength(2)
expect(tooltipSeries).toHaveLength(3)
expect(tooltipSeries.map((item) => item.text())).toEqual([
expect.stringContaining('BT2_2M'),
expect.stringContaining('TEST_CH_1'),
expect.stringContaining('REF_CH_1'),
])
})
@@ -268,6 +282,18 @@ describe('WaveformChart', () => {
],
},
},
{
id: 'third',
trackId: 'shared',
name: 'third',
data: {
kind: 'points',
points: [
{ x: 0, y: 2 },
{ x: 1, y: 3 },
],
},
},
],
},
{ grid: { rowCount: 1, columnCount: 1 } },

View File

@@ -42,6 +42,19 @@ const presentationData: WaveformData = {
],
},
},
{
id: 'mid',
trackId: 'shared',
name: '中量程',
data: {
kind: 'points',
points: [
{ x: 0, y: 50 },
{ x: 1, y: 100 },
{ x: 2, y: 75 },
],
},
},
{
id: 'other',
name: '其他通道',
@@ -150,9 +163,9 @@ describe('WaveformChart presentation mode', () => {
cancelable: true,
}),
)
wrapper.get('.waveform-chart__svg').element.dispatchEvent(
new MouseEvent('dblclick', { bubbles: true, cancelable: true }),
)
wrapper
.get('.waveform-chart__svg')
.element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
await wrapper.get('[data-annotation-id="note"]').trigger('contextmenu', {
clientX: width / 2,
clientY: height / 2,

View File

@@ -119,6 +119,108 @@ describe('WaveformChart viewport reset', () => {
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1000')
})
it('keeps a controlled shared viewport when the data reference changes', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
],
},
{ displayMode: 'separated' },
)
const chart = wrapper.vm as unknown as {
setViewportDomain: (domain: [number, number], trackIndex?: number) => void
}
chart.setViewportDomain([0.25, 0.75])
await flushPromises()
const startBefore = Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())
const endBefore = Number(wrapper.get('.waveform-chart__axis-endpoint--end').text())
expect(startBefore).toBeGreaterThan(0)
expect(endBefore).toBeLessThan(1000)
await wrapper.setProps({
data: {
kind: 'points',
points: [
{ x: 0, y: 10 },
{ x: 1, y: 11 },
],
},
})
await flushPromises()
expect(Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())).toBeCloseTo(
startBefore,
10,
)
expect(Number(wrapper.get('.waveform-chart__axis-endpoint--end').text())).toBeCloseTo(
endBefore,
10,
)
})
it('keeps independently controlled track viewports when the data reference changes', async () => {
const wrapper = await mountSizedChart(gridSeries(2), {
displayMode: 'independent',
grid: { rowCount: 1, columnCount: 2 },
})
const chart = wrapper.vm as unknown as {
setViewportDomain: (domain: [number, number], trackIndex?: number) => void
}
chart.setViewportDomain([0.2, 0.8], 0)
chart.setViewportDomain([0.1, 0.9], 1)
await flushPromises()
const endpoints = () =>
wrapper.findAll('.waveform-chart__track').map((track) => ({
start: track.get('.waveform-chart__axis-endpoint--start').text(),
end: track.get('.waveform-chart__axis-endpoint--end').text(),
}))
const endpointsBefore = endpoints()
expect(Number(endpointsBefore[0].start)).toBeGreaterThan(0)
expect(Number(endpointsBefore[0].end)).toBeLessThan(1000)
expect(Number(endpointsBefore[1].start)).toBeGreaterThan(0)
expect(Number(endpointsBefore[1].end)).toBeLessThan(1000)
await wrapper.setProps({ data: gridSeries(2) })
await flushPromises()
const endpointsAfter = endpoints()
endpointsAfter.forEach((endpoint, index) => {
expect(Number(endpoint.start)).toBeCloseTo(Number(endpointsBefore[index].start), 10)
expect(Number(endpoint.end)).toBeCloseTo(Number(endpointsBefore[index].end), 10)
})
})
it('ignores invalid domains and applies viewport constraints', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 1 },
],
},
{ displayMode: 'separated', minZoomSpan: 0.2 },
)
const chart = wrapper.vm as unknown as {
setViewportDomain: (domain: [number, number], trackIndex?: number) => void
}
chart.setViewportDomain([Number.NaN, 0.5])
await flushPromises()
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1000')
chart.setViewportDomain([0.49, 0.51])
await flushPromises()
const start = Number(wrapper.get('.waveform-chart__axis-endpoint--start').text())
const end = Number(wrapper.get('.waveform-chart__axis-endpoint--end').text())
expect(end - start).toBeGreaterThanOrEqual(200)
})
it('resets shared and independent explicit domains to their included nice bounds', async () => {
const shared = await mountSizedChart(
{

View File

@@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import { createSimulatedWaveformData } from './simulatedWaveforms'
describe('simulated waveform data', () => {
it('creates deterministic, finite six-channel data', () => {
it('creates deterministic, finite seven-channel data with a two-series second frame', () => {
const first = createSimulatedWaveformData()
const second = createSimulatedWaveformData()
@@ -11,8 +11,12 @@ describe('simulated waveform data', () => {
expect(first.kind).toBe('series')
if (first.kind !== 'series') return
expect(first.series).toHaveLength(6)
expect(new Set(first.series.map((series) => series.id)).size).toBe(6)
expect(first.series).toHaveLength(7)
expect(new Set(first.series.map((series) => series.id)).size).toBe(7)
const secondFrame = first.series.filter(
(series) => series.trackId === 'simulated-harmonic-frame',
)
expect(secondFrame).toHaveLength(2)
expect(new Set(first.series.map((series) => series.shotNo))).toEqual(new Set(['13300']))
first.series.forEach((series) => {
expect(series.data.kind).toBe('points')

View File

@@ -13,7 +13,7 @@ type ErrorGenerator = (
interface SimulatedSeriesDefinition extends Pick<
WaveformSeries,
'id' | 'shotNo' | 'name' | 'unit' | 'lineType' | 'pointType' | 'errorBar'
'id' | 'trackId' | 'shotNo' | 'name' | 'unit' | 'color' | 'lineType' | 'pointType' | 'errorBar'
> {
signal: SignalGenerator
errors?: ErrorGenerator
@@ -60,6 +60,7 @@ const seriesDefinitions: SimulatedSeriesDefinition[] = [
},
{
id: 'simulated-harmonic',
trackId: 'simulated-harmonic-frame',
shotNo: '13300',
name: '谐波扰动',
unit: 'V',
@@ -68,6 +69,18 @@ const seriesDefinitions: SimulatedSeriesDefinition[] = [
signal: (time) =>
0.9 * Math.sin(TWO_PI * 0.55 * time) + 0.28 * Math.sin(TWO_PI * 2.2 * time + 0.4),
},
{
id: 'simulated-harmonic-reference',
trackId: 'simulated-harmonic-frame',
shotNo: '13300',
name: '谐波对比',
unit: 'V',
color: '#2ca02c',
lineType: 'linear',
pointType: 'none',
signal: (time) =>
0.62 * Math.sin(TWO_PI * 0.55 * time + 0.55) + 0.18 * Math.sin(TWO_PI * 2.2 * time - 0.2),
},
{
id: 'simulated-damped',
shotNo: '13300',

View File

@@ -58,6 +58,18 @@ export function visibilitySeries(): WaveformData {
],
},
},
{
id: 'mid',
trackId: 'shared-frame',
name: '中量程',
data: {
kind: 'points',
points: [
{ x: 0, y: 100 },
{ x: 1, y: 200 },
],
},
},
],
}
}