diff --git a/README.md b/README.md index 5a8098e..4d1735d 100644 --- a/README.md +++ b/README.md @@ -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、标注或缩放事件值: diff --git a/package.json b/package.json index e602663..69aab14 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "waveform-analysis", - "version": "0.1.38", + "version": "0.1.39", "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 1b8338a..716c215 100644 --- a/src/components/WaveformChart.vue +++ b/src/components/WaveformChart.vue @@ -36,6 +36,7 @@ const controller = useWaveformChartController(props as ResolvedWaveformChartProp defineExpose({ resetViewport: controller.resetViewport, + setViewportDomain: controller.setViewportDomain, }) diff --git a/src/components/core/useWaveformChartController.ts b/src/components/core/useWaveformChartController.ts index 30287d9..e197c76 100644 --- a/src/components/core/useWaveformChartController.ts +++ b/src/components/core/useWaveformChartController.ts @@ -259,6 +259,9 @@ export function useWaveformChartController( trackLayouts, innerWidth, innerHeight, + sharedZoomDomain, + initialXDomain, + sharedTransform, activeInteractionMode, hiddenSeriesIdSet, internalHiddenSeriesIds, diff --git a/src/components/core/useWaveformChartLifecycle.ts b/src/components/core/useWaveformChartLifecycle.ts index 2ea258f..2cd3b1d 100644 --- a/src/components/core/useWaveformChartLifecycle.ts +++ b/src/components/core/useWaveformChartLifecycle.ts @@ -39,6 +39,9 @@ interface LifecycleContext { trackLayouts: ComputedRef innerWidth: ComputedRef innerHeight: ComputedRef + sharedZoomDomain: ComputedRef<[number, number]> + initialXDomain: ComputedRef<[number, number]> + sharedTransform: ShallowRef activeInteractionMode: ComputedRef hiddenSeriesIdSet: ComputedRef> internalHiddenSeriesIds: Ref> @@ -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 - 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] - }) + if (props.displayMode === 'independent') { + pendingSharedXDomain = undefined + pendingIndependentXDomains = trackLayouts.value.map((track) => { + 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() }) } diff --git a/src/components/interaction/useWaveformViewport.ts b/src/components/interaction/useWaveformViewport.ts index 5abbe22..6463209 100644 --- a/src/components/interaction/useWaveformViewport.ts +++ b/src/components/interaction/useWaveformViewport.ts @@ -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, } } diff --git a/src/components/interaction/viewportDomain.ts b/src/components/interaction/viewportDomain.ts new file mode 100644 index 0000000..96d23c7 --- /dev/null +++ b/src/components/interaction/viewportDomain.ts @@ -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 + initialXDomain: ComputedRef<[number, number]> + resolveInitialTrackDomain: (track: TrackLayout) => [number, number] + innerWidth: ComputedRef + sharedTransform: ShallowRef + independentTransforms: ShallowRef + cancelPendingZoom: () => void + cancelViewportDrag: () => void + clearHover: () => void + editorSeriesOptions: Ref + 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) + } +} diff --git a/src/components/waveformChartCases/viewportReset.test.ts b/src/components/waveformChartCases/viewportReset.test.ts index 76513dd..efb466d 100644 --- a/src/components/waveformChartCases/viewportReset.test.ts +++ b/src/components/waveformChartCases/viewportReset.test.ts @@ -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( {