feat(视口): 支持恢复受控缩放范围
All checks were successful
Package component / package (push) Successful in 4m38s
All checks were successful
Package component / package (push) Successful in 4m38s
This commit is contained in:
14
README.md
14
README.md
@@ -253,6 +253,20 @@ X 轴且包含多个轨道时使用按稳定 track ID 索引的 `yRanges`。平
|
|||||||
重置组件内部缩放并触发 `zoom-reset`;调用方应在事件中取消区间请求并恢复首次完整数据。
|
重置组件内部缩放并触发 `zoom-reset`;调用方应在事件中取消区间请求并恢复首次完整数据。
|
||||||
外部重置按钮也可以通过模板引用调用组件公开的 `resetViewport()` 方法,然后执行相同的数据恢复逻辑。
|
外部重置按钮也可以通过模板引用调用组件公开的 `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` 将数据范围扩展为便于阅读的视口端点。
|
没有显式配置初始范围时,可以通过 `xDomainStrategy` 将数据范围扩展为便于阅读的视口端点。
|
||||||
默认的 `{ type: 'data' }` 保持数据最小值和最大值不变;`type: 'nice'` 使用固定刻度数量计算
|
默认的 `{ type: 'data' }` 保持数据最小值和最大值不变;`type: 'nice'` 使用固定刻度数量计算
|
||||||
易读边界,且只扩展视口,不修改原始点位、tooltip、标注或缩放事件值:
|
易读边界,且只扩展视口,不修改原始点位、tooltip、标注或缩放事件值:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "waveform-analysis",
|
"name": "waveform-analysis",
|
||||||
"version": "0.1.38",
|
"version": "0.1.39",
|
||||||
"main": "./dist/index.cjs",
|
"main": "./dist/index.cjs",
|
||||||
"module": "./dist/index.js",
|
"module": "./dist/index.js",
|
||||||
"types": "./dist/types/index.d.ts",
|
"types": "./dist/types/index.d.ts",
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const controller = useWaveformChartController(props as ResolvedWaveformChartProp
|
|||||||
|
|
||||||
defineExpose({
|
defineExpose({
|
||||||
resetViewport: controller.resetViewport,
|
resetViewport: controller.resetViewport,
|
||||||
|
setViewportDomain: controller.setViewportDomain,
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -259,6 +259,9 @@ export function useWaveformChartController(
|
|||||||
trackLayouts,
|
trackLayouts,
|
||||||
innerWidth,
|
innerWidth,
|
||||||
innerHeight,
|
innerHeight,
|
||||||
|
sharedZoomDomain,
|
||||||
|
initialXDomain,
|
||||||
|
sharedTransform,
|
||||||
activeInteractionMode,
|
activeInteractionMode,
|
||||||
hiddenSeriesIdSet,
|
hiddenSeriesIdSet,
|
||||||
internalHiddenSeriesIds,
|
internalHiddenSeriesIds,
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ interface LifecycleContext {
|
|||||||
trackLayouts: ComputedRef<TrackLayout[]>
|
trackLayouts: ComputedRef<TrackLayout[]>
|
||||||
innerWidth: ComputedRef<number>
|
innerWidth: ComputedRef<number>
|
||||||
innerHeight: ComputedRef<number>
|
innerHeight: ComputedRef<number>
|
||||||
|
sharedZoomDomain: ComputedRef<[number, number]>
|
||||||
|
initialXDomain: ComputedRef<[number, number]>
|
||||||
|
sharedTransform: ShallowRef<ZoomTransform>
|
||||||
activeInteractionMode: ComputedRef<string | undefined>
|
activeInteractionMode: ComputedRef<string | undefined>
|
||||||
hiddenSeriesIdSet: ComputedRef<Set<string>>
|
hiddenSeriesIdSet: ComputedRef<Set<string>>
|
||||||
internalHiddenSeriesIds: Ref<Set<string>>
|
internalHiddenSeriesIds: Ref<Set<string>>
|
||||||
@@ -93,6 +96,9 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
trackLayouts,
|
trackLayouts,
|
||||||
innerWidth,
|
innerWidth,
|
||||||
innerHeight,
|
innerHeight,
|
||||||
|
sharedZoomDomain,
|
||||||
|
initialXDomain,
|
||||||
|
sharedTransform,
|
||||||
activeInteractionMode,
|
activeInteractionMode,
|
||||||
hiddenSeriesIdSet,
|
hiddenSeriesIdSet,
|
||||||
internalHiddenSeriesIds,
|
internalHiddenSeriesIds,
|
||||||
@@ -144,25 +150,24 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
emit('page-change', nextPage, pageCount.value)
|
emit('page-change', nextPage, pageCount.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let pendingSharedXDomain: [number, number] | undefined
|
||||||
let pendingIndependentXDomains: Array<[number, number] | undefined> | undefined
|
let pendingIndependentXDomains: Array<[number, number] | undefined> | undefined
|
||||||
|
|
||||||
function handleBeforeDataReferenceChange() {
|
function handleBeforeDataReferenceChange() {
|
||||||
if (props.displayMode !== 'independent') return
|
if (props.displayMode === 'independent') {
|
||||||
|
pendingSharedXDomain = undefined
|
||||||
pendingIndependentXDomains = trackLayouts.value.map((track) => {
|
pendingIndependentXDomains = trackLayouts.value.map((track) => {
|
||||||
const configuredDomain =
|
const current = track.xScale.domain() as [number, number]
|
||||||
props.initialXDomains?.[track.series.trackId ?? track.series.id] ??
|
const boundary = resolveInitialTrackDomain(track)
|
||||||
props.initialXDomains?.[track.series.id] ??
|
return current[1] - current[0] < boundary[1] - boundary[0] - 1e-12 ? current : undefined
|
||||||
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]
|
|
||||||
})
|
})
|
||||||
|
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() {
|
function handleDataReferenceChange() {
|
||||||
@@ -186,10 +191,15 @@ export function useWaveformChartLifecycle(context: LifecycleContext) {
|
|||||||
})
|
})
|
||||||
independentTransforms.value = nextTransforms
|
independentTransforms.value = nextTransforms
|
||||||
pendingIndependentXDomains = undefined
|
pendingIndependentXDomains = undefined
|
||||||
void nextTick(configureZoom)
|
} else if (props.displayMode !== 'independent' && pendingSharedXDomain) {
|
||||||
return
|
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()
|
configureZoom()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import type {
|
|||||||
import type { AnnotationSeriesCandidate } from '../annotation'
|
import type { AnnotationSeriesCandidate } from '../annotation'
|
||||||
import { tryReleasePointerCapture } from './pointerCapture'
|
import { tryReleasePointerCapture } from './pointerCapture'
|
||||||
import { transitionViewportInteraction } from './viewportInteractionState'
|
import { transitionViewportInteraction } from './viewportInteractionState'
|
||||||
|
import { createViewportDomainSetter } from './viewportDomain'
|
||||||
import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
|
import { constrainZoomDomain, transformForDomain } from './zoomConstraints'
|
||||||
interface ViewportContext {
|
interface ViewportContext {
|
||||||
props: ResolvedWaveformChartProps
|
props: ResolvedWaveformChartProps
|
||||||
@@ -344,6 +345,20 @@ export function useWaveformViewport(context: ViewportContext) {
|
|||||||
editorSeriesOptions.value = []
|
editorSeriesOptions.value = []
|
||||||
void nextTick(configureZoom)
|
void nextTick(configureZoom)
|
||||||
}
|
}
|
||||||
|
const setViewportDomain = createViewportDomainSetter({
|
||||||
|
props,
|
||||||
|
trackLayouts,
|
||||||
|
initialXDomain,
|
||||||
|
resolveInitialTrackDomain,
|
||||||
|
innerWidth,
|
||||||
|
sharedTransform,
|
||||||
|
independentTransforms,
|
||||||
|
cancelPendingZoom,
|
||||||
|
cancelViewportDrag,
|
||||||
|
clearHover,
|
||||||
|
editorSeriesOptions,
|
||||||
|
configureZoom,
|
||||||
|
})
|
||||||
const requestViewportReset = (event: MouseEvent) => {
|
const requestViewportReset = (event: MouseEvent) => {
|
||||||
if (isPresentationMode.value || !props.zoomable || !isZoomMode.value) return
|
if (isPresentationMode.value || !props.zoomable || !isZoomMode.value) return
|
||||||
if (props.displayMode === 'independent') {
|
if (props.displayMode === 'independent') {
|
||||||
@@ -372,6 +387,7 @@ export function useWaveformViewport(context: ViewportContext) {
|
|||||||
finishViewportDrag,
|
finishViewportDrag,
|
||||||
cancelViewportDrag,
|
cancelViewportDrag,
|
||||||
resetViewport,
|
resetViewport,
|
||||||
|
setViewportDomain,
|
||||||
requestViewportReset,
|
requestViewportReset,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
60
src/components/interaction/viewportDomain.ts
Normal file
60
src/components/interaction/viewportDomain.ts
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -119,6 +119,108 @@ describe('WaveformChart viewport reset', () => {
|
|||||||
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1000')
|
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 () => {
|
it('resets shared and independent explicit domains to their included nice bounds', async () => {
|
||||||
const shared = await mountSizedChart(
|
const shared = await mountSizedChart(
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user