feat(chart): enhance zoom viewport interactions
All checks were successful
Package component / package (push) Successful in 3m43s

This commit is contained in:
李启源
2026-07-21 15:41:46 +08:00
parent 3c1c29fa56
commit 65a0c4933c
10 changed files with 1256 additions and 73 deletions

View File

@@ -1261,6 +1261,75 @@ describe('WaveformChart', () => {
}
})
it('uses each track x domain in independent mode instead of the shared initial domain', async () => {
const wrapper = await mountSizedChart(
{
kind: 'series',
series: [
{
id: 'narrow-track',
name: '窄范围',
data: {
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 0.006, y: 1 },
],
},
},
{
id: 'wide-track',
name: '宽范围',
data: {
kind: 'points',
points: [
{ x: -8, y: 0 },
{ x: 5, y: 1 },
],
},
},
],
},
{
displayMode: 'independent',
grid: { rowCount: 1, columnCount: 2 },
initialXDomain: [-8, 5],
},
)
const tracks = wrapper.findAll('.waveform-chart__track')
expect(tracks[0]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('6.00')
expect(tracks[1]?.get('.waveform-chart__axis-endpoint--end').text()).toBe('5.00')
})
it('uses an explicit initial x domain override for an independent track', async () => {
const wrapper = await mountSizedChart(
{
kind: 'series',
series: [
{
id: 'narrow-track',
name: '窄范围',
data: {
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 0.006, y: 1 },
],
},
},
],
},
{
displayMode: 'independent',
initialXDomain: [-8, 5],
initialXDomains: { 'narrow-track': [0, 1] },
},
)
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('1.00')
})
it('keeps annotations bound to their channel while paging', async () => {
const wrapper = await mountSizedChart(gridSeries(3), {
grid: { rowCount: 1, columnCount: 1 },
@@ -1838,6 +1907,9 @@ describe('WaveformChart', () => {
).toBeUndefined()
expect(wrapper.get('.waveform-chart__plot-background').attributes('fill')).toBe('transparent')
expect(wrapper.get('.waveform-chart__watermark').text()).toBe('12')
expect(getComputedStyle(wrapper.get('.waveform-chart__watermark').element).userSelect).toBe(
'none',
)
expect(wrapper.get('.waveform-chart__line').attributes('stroke')).toBe('#0960bd')
})
@@ -2122,11 +2194,207 @@ describe('WaveformChart', () => {
expect(payload.start).toBeGreaterThanOrEqual(0)
expect(payload.end).toBeLessThanOrEqual(2)
expect(payload.start).toBeLessThan(payload.end)
expect(payload.end - payload.start).toBeCloseTo(2 / 40)
} finally {
vi.useRealTimers()
}
})
it('zooms only the x axis and identifies the box gesture', async () => {
const wrapper = await mountSizedChart({
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 10 },
{ x: 2, y: 20 },
],
})
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 dispatchPointer = (type: string, clientX: number, clientY: number) => {
const event = new MouseEvent(type, { button: 0, clientX, clientY, bubbles: true })
Object.defineProperty(event, 'pointerId', { value: 7 })
overlay.element.dispatchEvent(event)
}
dispatchPointer('pointerdown', width * 0.25, height / 2)
dispatchPointer('pointermove', width * 0.75, height / 2 + 2)
await flushPromises()
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(true)
dispatchPointer('pointerup', width * 0.75, height * 0.75)
await flushPromises()
const payload = wrapper.emitted('zoom-end')?.at(-1)?.[0] as
| {
start: number
end: number
yStart?: number
yEnd?: number
trackIndex: number
seriesIds: string[]
gesture: string
}
| undefined
expect(payload).toMatchObject({ trackIndex: 0, seriesIds: ['series-0'], gesture: 'box' })
expect(payload?.end).toBeGreaterThan(payload?.start ?? Number.POSITIVE_INFINITY)
expect(payload?.yStart).toBeDefined()
expect(payload?.yEnd).toBeDefined()
expect(wrapper.find('.waveform-chart__zoom-selection').exists()).toBe(false)
})
it('limits box zoom to the configured minimum x span', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 10 },
{ x: 2, y: 20 },
],
},
{ minZoomSpan: 0.25 },
)
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 dispatchPointer = (type: string, clientX: number, clientY: number) => {
const event = new MouseEvent(type, { button: 0, clientX, clientY, bubbles: true })
Object.defineProperty(event, 'pointerId', { value: 8 })
overlay.element.dispatchEvent(event)
}
dispatchPointer('pointerdown', width / 2, height / 2)
dispatchPointer('pointermove', width / 2 + 8, height / 2 + 8)
dispatchPointer('pointerup', width / 2 + 8, height / 2 + 8)
await flushPromises()
const payload = wrapper.emitted('zoom-end')?.at(-1)?.[0] as { start: number; end: number }
expect(payload.end - payload.start).toBeGreaterThanOrEqual(0.25 - 1e-8)
})
it('does not zoom a track when its visible sample count is below the configured minimum', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 1, y: 10 },
{ x: 2, y: 20 },
],
},
{ minVisiblePoints: 10 },
)
const overlay = wrapper.get('.waveform-chart__overlay--independent')
const width = Number(overlay.attributes('width'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height: 290 }),
})
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -1000,
clientX: width / 2,
clientY: 145,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
expect(wrapper.emitted('zoom-change')).toBeUndefined()
expect(wrapper.emitted('zoom-end')).toBeUndefined()
})
it('ignores shared viewport dragging', async () => {
const wrapper = await mountSizedChart({
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 2, y: 1 },
],
})
const overlay = wrapper.get('.waveform-chart__overlay')
const width = Number(overlay.attributes('width'))
const createDragEvent = (type: string, init: MouseEventInit) => {
const event = new MouseEvent(type, init)
Object.defineProperty(event, 'view', { value: window })
return event
}
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height: 290 }),
})
overlay.element.dispatchEvent(
createDragEvent('mousedown', {
button: 0,
clientX: width / 2,
clientY: 145,
bubbles: true,
cancelable: true,
}),
)
window.dispatchEvent(
new MouseEvent('mousemove', {
clientX: width / 2 + 80,
clientY: 145,
bubbles: true,
}),
)
window.dispatchEvent(createDragEvent('mouseup', { bubbles: true }))
flushAnimationFrames()
await flushPromises()
expect(wrapper.emitted('zoom-change')).toBeUndefined()
expect(wrapper.emitted('zoom-end')).toBeUndefined()
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0.00')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
})
it('ignores independent viewport dragging', async () => {
const wrapper = await mountSizedChart(gridSeries(2), {
displayMode: 'independent',
grid: { rowCount: 1, columnCount: 2 },
})
const overlay = wrapper.findAll('.waveform-chart__overlay--independent')[0]
const width = Number(overlay.attributes('width'))
const createDragEvent = (type: string, init: MouseEventInit) => {
const event = new MouseEvent(type, init)
Object.defineProperty(event, 'view', { value: window })
return event
}
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height: 260 }),
})
overlay.element.dispatchEvent(
createDragEvent('mousedown', {
button: 0,
clientX: width / 2,
clientY: 130,
bubbles: true,
cancelable: true,
}),
)
window.dispatchEvent(
new MouseEvent('mousemove', {
clientX: width / 2 + 60,
clientY: 130,
bubbles: true,
}),
)
window.dispatchEvent(createDragEvent('mouseup', { bubbles: true }))
flushAnimationFrames()
await flushPromises()
expect(wrapper.emitted('zoom-change')).toBeUndefined()
expect(wrapper.emitted('zoom-end')).toBeUndefined()
})
it('includes track and series IDs in independent zoom-end payloads', async () => {
vi.useFakeTimers()
try {
@@ -2161,6 +2429,222 @@ describe('WaveformChart', () => {
}
})
it('keeps the global minimum zoom span after replacing the data window', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 100, y: 1 },
],
},
{ minZoomSpan: 10 },
)
const overlay = wrapper.get('.waveform-chart__overlay')
const width = Number(overlay.attributes('width'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height: 290 }),
})
const zoomAtCenter = async () => {
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -4000,
clientX: width / 2,
clientY: 145,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
}
await zoomAtCenter()
const firstDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
expect(firstDomain[1] - firstDomain[0]).toBeCloseTo(10)
await wrapper.setProps({
data: {
kind: 'points',
points: [
{ x: firstDomain[0], y: 0 },
{ x: firstDomain[1], y: 1 },
],
},
})
await flushPromises()
await zoomAtCenter()
const secondDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
expect(secondDomain[1] - secondDomain[0]).toBeCloseTo(10)
})
it('keeps one global domain while loading narrower and wider windows', async () => {
const wrapper = await mountSizedChart(
{
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 100, y: 1 },
],
},
{ displayMode: 'separated', initialXDomain: [0, 100] },
)
const overlay = wrapper.get('.waveform-chart__overlay')
const width = Number(overlay.attributes('width'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height: 290 }),
})
const dispatchWheel = async (deltaY: number) => {
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY,
clientX: width / 2,
clientY: 145,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
}
await dispatchWheel(-4000)
const zoomedDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
expect(zoomedDomain[1] - zoomedDomain[0]).toBeCloseTo(2.5)
await wrapper.setProps({
data: {
kind: 'points',
points: [
{ x: 48, y: 0 },
{ x: 52, y: 1 },
],
},
})
await flushPromises()
const preservedDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
expect(preservedDomain[1] - preservedDomain[0]).toBeCloseTo(2.5)
const eventCount = wrapper.emitted('zoom-change')?.length ?? 0
await dispatchWheel(4000)
const currentDomain = wrapper.emitted('zoom-change')?.at(-1)?.[0] as [number, number]
expect(wrapper.emitted('zoom-change')?.length ?? 0).toBe(eventCount)
expect(currentDomain[1] - currentDomain[0]).toBeCloseTo(2.5)
})
it('resets a shared viewport on double-click and emits zoom-reset', async () => {
const wrapper = await mountSizedChart({
kind: 'points',
points: [
{ x: 0, y: 0 },
{ x: 2, y: 1 },
],
})
const overlay = wrapper.get('.waveform-chart__overlay')
const width = Number(overlay.attributes('width'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height: 290 }),
})
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -4000,
clientX: width / 2,
clientY: 145,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).not.toBe('0.00')
overlay.element.dispatchEvent(new MouseEvent('dblclick', { bubbles: true, cancelable: true }))
await flushPromises()
expect(wrapper.emitted('zoom-reset')).toHaveLength(1)
expect(wrapper.get('.waveform-chart__axis-endpoint--start').text()).toBe('0.00')
expect(wrapper.get('.waveform-chart__axis-endpoint--end').text()).toBe('2.00')
})
it('exposes resetViewport for independent tracks', async () => {
const wrapper = await mountSizedChart(gridSeries(2), {
displayMode: 'independent',
grid: { rowCount: 1, columnCount: 2 },
})
const overlay = wrapper.findAll('.waveform-chart__overlay--independent')[0]
const width = Number(overlay.attributes('width'))
Object.defineProperty(overlay.element, 'getBoundingClientRect', {
value: () => ({ left: 0, top: 0, width, height: 260 }),
})
overlay.element.dispatchEvent(
new WheelEvent('wheel', {
deltaY: -4000,
clientX: width / 2,
clientY: 130,
bubbles: true,
cancelable: true,
}),
)
flushAnimationFrames()
await flushPromises()
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).not.toBe('0.00')
const chart = wrapper.vm as unknown as { resetViewport: () => void }
chart.resetViewport()
await flushPromises()
expect(wrapper.findAll('.waveform-chart__axis-endpoint--start')[0].text()).toBe('0.00')
expect(wrapper.findAll('.waveform-chart__axis-endpoint--end')[0].text()).toBe('1.00')
})
it('keeps other independent tracks unchanged when one data window is replaced', async () => {
const wrapper = await mountSizedChart(gridSeries(2), {
displayMode: 'independent',
grid: { rowCount: 1, columnCount: 2 },
})
const originalEndpoints = wrapper
.findAll('.waveform-chart__axis-endpoint--end')
.map((endpoint) => endpoint.text())
await wrapper.setProps({
data: {
kind: 'series',
series: [
{
id: 'channel-0',
name: '通道 1',
data: {
kind: 'points',
points: [
{ x: 0.25, y: 0 },
{ x: 0.75, y: 1 },
],
},
},
{
id: 'channel-1',
name: '通道 2',
data: {
kind: 'points',
points: [
{ x: 0, y: 1 },
{ x: 1, y: 2 },
],
},
},
],
},
})
await flushPromises()
const nextEndpoints = wrapper
.findAll('.waveform-chart__axis-endpoint--end')
.map((endpoint) => endpoint.text())
expect(nextEndpoints[0]).not.toBe(originalEndpoints[0])
expect(nextEndpoints[1]).toBe(originalEndpoints[1])
})
it('rebuilds cached domains only when the data reference changes', async () => {
const firstData: WaveformData = {
kind: 'points',

View File

@@ -92,6 +92,10 @@ const props = withDefaults(
lineColor?: string
showTooltip?: boolean
zoomable?: boolean
minZoomSpan?: number
minVisiblePoints?: number
initialXDomain?: [number, number]
initialXDomains?: Record<string, [number, number]>
timeUnit?: 's' | 'ms'
frameNumber?: string | number
frameStyle?: WaveformFrameStyle
@@ -113,6 +117,7 @@ const props = withDefaults(
lineColor: '#0960bd',
showTooltip: true,
zoomable: true,
minVisiblePoints: 0,
timeUnit: 'ms',
frameNumber: undefined,
annotations: () => [],
@@ -130,6 +135,7 @@ const emit = defineEmits<{
'point-hover': [point: WaveformPoint | null]
'zoom-change': [domain: [number, number]]
'zoom-end': [payload: WaveformZoomEndPayload]
'zoom-reset': []
'update:annotations': [annotations: WaveformAnnotation[]]
'update:annotations-visible': [visible: boolean]
'update:interaction-mode': [mode: WaveformInteractionMode]
@@ -159,6 +165,8 @@ const measuredTitleWidth = ref(0)
const measuredTitleHeight = ref(0)
const sharedTransform = shallowRef<ZoomTransform>(zoomIdentity)
const independentTransforms = shallowRef<ZoomTransform[]>([])
const sharedYDomains = ref<Record<string, [number, number]>>({})
const independentYDomains = ref<Record<number, [number, number]>>({})
const hoveredSeriesPoints = ref<HoveredSeriesPoint[]>([])
const hoveredTrackIndex = ref<number | null>(null)
const hoverPosition = ref({ x: 0, y: 0 })
@@ -174,12 +182,53 @@ const editorSeriesOptions = ref<AnnotationSeriesCandidate[]>([])
let generatedAnnotationId = 0
let synchronizingZoomTransform = false
let pendingSharedZoomTransform: ZoomTransform | null = null
type ZoomGestureKind = 'wheel'
let pendingSharedZoomGesture: ZoomGestureKind | null = null
let lastSharedZoomGesture: ZoomGestureKind | null = null
const pendingIndependentZoomTransforms = new Map<number, ZoomTransform>()
const pendingIndependentZoomGestures = new Map<number, ZoomGestureKind>()
const lastIndependentZoomGestures = new Map<number, ZoomGestureKind>()
const lastZoomedTrackIndexes = new Set<number>()
const zoomThrottle = useAnimationFrameThrottle()
const hoverThrottle = useAnimationFrameThrottle()
const preparedSeries = usePreparedWaveformSeries(() => props.data, handleDataReferenceChange)
interface SelectionState {
trackIndex: number
independent: boolean
overlay: SVGRectElement
startX: number
startY: number
currentX: number
currentY: number
pointerId: number
mode: 'box' | 'pan'
xDomain: [number, number]
yDomains: Record<string, [number, number]>
}
const selection = ref<SelectionState | null>(null)
const spacePressed = ref(false)
const selectionBox = computed(() => {
const active = selection.value
if (!active) return null
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
return {
x: Math.min(active.startX, active.currentX) + (active.independent ? (track?.left ?? 0) : 0),
y: Math.min(active.startY, active.currentY) + (active.independent ? (track?.top ?? 0) : 0),
width: Math.abs(active.currentX - active.startX),
height: Math.abs(active.currentY - active.startY),
}
})
function handleInteractionKeyDown(event: KeyboardEvent) {
if (event.code === 'Space') spacePressed.value = true
}
function handleInteractionKeyUp(event: KeyboardEvent) {
if (event.code === 'Space') spacePressed.value = false
}
// 用于传递给 WaveformTooltip 的接口
interface TooltipSeriesPoint {
trackIndex: number
@@ -475,10 +524,39 @@ const sharedXDomain = computed(() =>
chartTracks.value.flatMap((track) => (track.visibleSeries.length ? track.xDomain : [])),
),
)
const initialXDomain = computed<[number, number]>(() => {
const domain = props.initialXDomain
if (
domain &&
Number.isFinite(domain[0]) &&
Number.isFinite(domain[1]) &&
domain[0] !== domain[1]
) {
return domain[0] < domain[1] ? domain : [domain[1], domain[0]]
}
return sharedXDomain.value
})
function resolveInitialTrackDomain(track: TrackLayout): [number, number] {
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 configuredDomain[0] < configuredDomain[1]
? configuredDomain
: [configuredDomain[1], configuredDomain[0]]
}
return paddedDomain(track.seriesList.flatMap((series) => series.xDomain))
}
const sharedZoomDomain = computed(
() =>
sharedTransform.value
.rescaleX(scaleLinear(sharedXDomain.value, [0, innerWidth.value]))
.rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value]))
.domain() as [number, number],
)
@@ -502,6 +580,17 @@ const trackLayouts = computed<TrackLayout[]>(() =>
overlayMode: props.overlayMode,
independentTransforms: independentTransforms.value,
sharedZoomDomain: sharedZoomDomain.value,
initialXDomain: props.initialXDomain ? initialXDomain.value : undefined,
initialXDomains: props.initialXDomains,
yDomains:
props.displayMode === 'independent'
? Object.fromEntries(
chartTracks.value.flatMap((track, index) => {
const domain = independentYDomains.value[index]
return domain ? [[track.id, domain]] : []
}),
)
: sharedYDomains.value,
timeUnit: props.timeUnit,
rendering: renderingOptions.value,
hideSecondaryLabels: yAxisLayout.value.hideSecondaryLabels,
@@ -568,6 +657,7 @@ function handleSharedZoom(event: D3ZoomEvent<SVGRectElement, unknown>) {
if (synchronizingZoomTransform) return
cancelPendingHover()
pendingSharedZoomTransform = event.transform
pendingSharedZoomGesture = 'wheel'
scheduleZoomCommit()
}
@@ -575,6 +665,7 @@ function handleIndependentZoom(event: D3ZoomEvent<SVGRectElement, unknown>, trac
if (synchronizingZoomTransform) return
cancelPendingHover()
pendingIndependentZoomTransforms.set(trackIndex, event.transform)
pendingIndependentZoomGestures.set(trackIndex, 'wheel')
scheduleZoomCommit()
}
@@ -582,9 +673,11 @@ function commitPendingZoom() {
if (pendingSharedZoomTransform) {
const transform = pendingSharedZoomTransform
pendingSharedZoomTransform = null
lastSharedZoomGesture = pendingSharedZoomGesture
pendingSharedZoomGesture = null
sharedTransform.value = transform
const domain = transform
.rescaleX(scaleLinear(sharedXDomain.value, [0, innerWidth.value]))
.rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value]))
.domain()
emit('zoom-change', [domain[0], domain[1]])
}
@@ -595,10 +688,15 @@ function commitPendingZoom() {
// Clear stale track indexes before recording the new batch
lastZoomedTrackIndexes.clear()
changedTrackIndexes.forEach((trackIndex) => lastZoomedTrackIndexes.add(trackIndex))
changedTrackIndexes.forEach((trackIndex) => {
const gesture = pendingIndependentZoomGestures.get(trackIndex)
if (gesture) lastIndependentZoomGestures.set(trackIndex, gesture)
})
pendingIndependentZoomTransforms.forEach((transform, trackIndex) => {
nextTransforms[trackIndex] = transform
})
pendingIndependentZoomTransforms.clear()
pendingIndependentZoomGestures.clear()
independentTransforms.value = nextTransforms
changedTrackIndexes.forEach((trackIndex) => {
const track = trackLayouts.value.find((item) => item.index === trackIndex)
@@ -622,29 +720,58 @@ function flushPendingZoom() {
function emitZoomEnd() {
if (props.displayMode === 'independent') {
lastZoomedTrackIndexes.forEach((trackIndex) => {
const gesture = lastIndependentZoomGestures.get(trackIndex)
if (gesture !== 'wheel') return
const track = trackLayouts.value.find((item) => item.index === trackIndex)
if (!track) return
const domain = track.xScale.domain() as [number, number]
emit('zoom-end', {
start: domain[0],
end: domain[1],
yStart: track.yScale.domain()[0],
yEnd: track.yScale.domain()[1],
trackIndex,
seriesIds: track.seriesList.map((series) => series.id),
gesture: 'wheel',
})
})
lastZoomedTrackIndexes.clear()
lastIndependentZoomGestures.clear()
return
}
if (lastSharedZoomGesture !== 'wheel') {
lastSharedZoomGesture = null
return
}
const domain = sharedZoomDomain.value
emit('zoom-end', { start: domain[0], end: domain[1] })
const visibleTracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
const payload: WaveformZoomEndPayload = { start: domain[0], end: domain[1], gesture: 'wheel' }
if (visibleTracks.length === 1) {
const yDomain = visibleTracks[0]?.yScale.domain()
payload.yStart = yDomain?.[0]
payload.yEnd = yDomain?.[1]
} else {
payload.yRanges = Object.fromEntries(
visibleTracks.map((track) => [
track.series.trackId ?? track.series.id,
track.yScale.domain() as [number, number],
]),
)
}
emit('zoom-end', payload)
lastSharedZoomGesture = null
}
function cancelPendingZoom() {
// Clear all pending zoom state to prevent stale emissions
pendingSharedZoomTransform = null
pendingSharedZoomGesture = null
lastSharedZoomGesture = null
pendingIndependentZoomTransforms.clear()
pendingIndependentZoomGestures.clear()
lastZoomedTrackIndexes.clear()
lastIndependentZoomGestures.clear()
zoomThrottle.cancel()
}
@@ -660,6 +787,35 @@ function clearZoomBindings() {
zoomBehaviors.clear()
}
function resolveMaximumZoomScale(domain: [number, number]): number {
const minZoomSpan = props.minZoomSpan
if (!Number.isFinite(minZoomSpan) || (minZoomSpan ?? 0) <= 0) return 40
const domainSpan = Math.abs(domain[1] - domain[0])
if (!Number.isFinite(domainSpan) || domainSpan <= 0) return 1
return Math.min(40, Math.max(1, domainSpan / (minZoomSpan ?? domainSpan)))
}
function visiblePointCount(track: TrackLayout): number {
const [start, end] = track.xScale.domain()
const xValues = new Set<number>()
track.seriesList.forEach((series) => {
series.points.forEach((point) => {
if (point.x >= start && point.x <= end) xValues.add(point.x)
})
})
return xValues.size
}
function canZoomTrack(track: TrackLayout): boolean {
const minimum = Number(props.minVisiblePoints)
return !Number.isFinite(minimum) || minimum <= 0 || visiblePointCount(track) >= minimum
}
function canZoomSharedTracks(): boolean {
const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
return tracks.length > 0 && tracks.every(canZoomTrack)
}
function configureZoom() {
clearZoomBindings()
if (!props.zoomable || !isZoomMode.value || !hasChartArea.value || !trackLayouts.value.length)
@@ -671,8 +827,13 @@ function configureZoom() {
`[data-independent-overlay-index="${track.index}"]`,
)
if (!overlay) return
const dataDomain = resolveInitialTrackDomain(track)
const behavior = zoom<SVGRectElement, unknown>()
.scaleExtent([1, 40])
.filter(
(event) =>
event.type === 'wheel' && (event as WheelEvent).deltaY < 0 && canZoomTrack(track),
)
.scaleExtent([1, resolveMaximumZoomScale(dataDomain)])
.extent([
[0, 0],
[track.width, track.height],
@@ -688,6 +849,7 @@ function configureZoom() {
try {
select(overlay)
.call(behavior)
.on('dblclick.zoom', null)
.call(behavior.transform, independentTransforms.value[track.index] ?? zoomIdentity)
} finally {
synchronizingZoomTransform = false
@@ -698,7 +860,11 @@ function configureZoom() {
if (!sharedOverlayElement.value) return
const behavior = zoom<SVGRectElement, unknown>()
.scaleExtent([1, 40])
.filter(
(event) =>
event.type === 'wheel' && (event as WheelEvent).deltaY < 0 && canZoomSharedTracks(),
)
.scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value)])
.extent([
[0, 0],
[innerWidth.value, innerHeight.value],
@@ -714,7 +880,10 @@ function configureZoom() {
if (overlay) {
synchronizingZoomTransform = true
try {
select(overlay).call(behavior).call(behavior.transform, sharedTransform.value)
select(overlay)
.call(behavior)
.on('dblclick.zoom', null)
.call(behavior.transform, sharedTransform.value)
} finally {
synchronizingZoomTransform = false
}
@@ -1100,6 +1269,10 @@ function confirmAnnotation(annotation: WaveformAnnotation) {
}
function handleIndependentPointerMove(event: PointerEvent, trackIndex: number) {
if (selection.value?.trackIndex === trackIndex && selection.value.independent) {
updateViewportDrag(event)
return
}
if (consumeHoverSuppression()) return
const overlay = event.currentTarget as SVGRectElement | null
if (!overlay) return
@@ -1126,6 +1299,10 @@ function handleIndependentPointerMove(event: PointerEvent, trackIndex: number) {
}
function handleSharedPointerMove(event: PointerEvent) {
if (selection.value?.overlay === event.currentTarget) {
updateViewportDrag(event)
return
}
if (consumeHoverSuppression()) return
if (!sharedOverlayElement.value || !trackLayouts.value.length) return
const [pointerX, pointerY] = pointer(event, sharedOverlayElement.value)
@@ -1152,15 +1329,260 @@ function handleSharedPointerMove(event: PointerEvent) {
})
}
function resetViewport() {
const minimumSelectionSize = 6
function transformForDomain(
domain: [number, number],
baseDomain: [number, number],
width: number,
): ZoomTransform {
const baseSpan = baseDomain[1] - baseDomain[0]
const span = domain[1] - domain[0]
if (!Number.isFinite(baseSpan) || !Number.isFinite(span) || baseSpan <= 0 || span <= 0) {
return zoomIdentity
}
const scale = baseSpan / span
const baseScale = scaleLinear(baseDomain, [0, width])
return zoomIdentity.translate(-scale * baseScale(domain[0]), 0).scale(scale)
}
function resolveMinimumZoomSpan(boundary: [number, number]): number {
const boundarySpan = Math.abs(boundary[1] - boundary[0])
if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return 0
const configured = props.minZoomSpan
if (Number.isFinite(configured) && (configured ?? 0) > 0) {
return Math.min(boundarySpan, configured as number)
}
return boundarySpan / 40
}
function constrainZoomDomain(
domain: [number, number],
boundary: [number, number],
): [number, number] {
const normalizedBoundary: [number, number] =
boundary[0] <= boundary[1] ? [...boundary] : [boundary[1], boundary[0]]
const boundarySpan = normalizedBoundary[1] - normalizedBoundary[0]
if (!Number.isFinite(boundarySpan) || boundarySpan <= 0) return normalizedBoundary
const requestedStart = Math.min(domain[0], domain[1])
const requestedEnd = Math.max(domain[0], domain[1])
const minimumSpan = resolveMinimumZoomSpan(normalizedBoundary)
const span = Math.max(minimumSpan, Math.min(boundarySpan, requestedEnd - requestedStart))
const center = (requestedStart + requestedEnd) / 2
const start = Math.max(
normalizedBoundary[0],
Math.min(center - span / 2, normalizedBoundary[1] - span),
)
return [start, start + span]
}
function clampDomain(domain: [number, number], boundary: [number, number]): [number, number] {
const span = domain[1] - domain[0]
const boundarySpan = boundary[1] - boundary[0]
if (span >= boundarySpan) return [...boundary]
if (domain[0] < boundary[0]) return [boundary[0], boundary[0] + span]
if (domain[1] > boundary[1]) return [boundary[1] - span, boundary[1]]
return domain
}
function currentYDomains(): Record<string, [number, number]> {
return Object.fromEntries(
trackLayouts.value.map((track) => [
track.series.trackId ?? track.series.id,
track.yScale.domain() as [number, number],
]),
)
}
function beginViewportDrag(event: PointerEvent, trackIndex: number, independent: boolean) {
if (!props.zoomable || !isZoomMode.value || event.button !== 0) return
const overlay = event.currentTarget as SVGRectElement
const track = trackLayouts.value.find((item) => item.index === trackIndex)
if (!track) return
const [rawX, rawY] = pointer(event, overlay)
const x = Math.max(0, Math.min(independent ? track.width : innerWidth.value, rawX))
const y = Math.max(0, Math.min(independent ? track.height : innerHeight.value, rawY))
selection.value = {
trackIndex,
independent,
overlay,
startX: x,
startY: y,
currentX: x,
currentY: y,
pointerId: event.pointerId,
mode: spacePressed.value ? 'pan' : 'box',
xDomain: track.xScale.domain() as [number, number],
yDomains: currentYDomains(),
}
overlay.setPointerCapture?.(event.pointerId)
clearHover()
event.preventDefault()
}
function beginSharedViewportDrag(event: PointerEvent) {
if (!sharedOverlayElement.value) return
const [x, y] = pointer(event, sharedOverlayElement.value)
const track =
resolveTrackAtPointer(x, y) ?? trackLayouts.value.find((item) => item.hasVisibleSeries)
if (track) beginViewportDrag(event, track.index, false)
}
function updateViewportDrag(event: PointerEvent) {
const active = selection.value
if (!active || event.pointerId !== active.pointerId) return
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
if (!track) return
const [rawX, rawY] = pointer(event, active.overlay)
active.currentX = Math.max(0, Math.min(active.independent ? track.width : innerWidth.value, rawX))
active.currentY = Math.max(
0,
Math.min(active.independent ? track.height : innerHeight.value, rawY),
)
selection.value = { ...active }
if (active.mode === 'pan') applyPan(active, track)
event.preventDefault()
}
function applyPan(active: SelectionState, track: TrackLayout) {
const width = track.width || 1
const height = track.height || 1
const dx = active.currentX - active.startX
const dy = active.currentY - active.startY
const xSpan = active.xDomain[1] - active.xDomain[0]
const initialDomain = active.independent ? resolveInitialTrackDomain(track) : initialXDomain.value
const nextX = clampDomain(
[active.xDomain[0] - (dx / width) * xSpan, active.xDomain[1] - (dx / width) * xSpan],
initialDomain,
)
if (active.independent) {
const next = [...independentTransforms.value]
next[track.index] = transformForDomain(nextX, initialDomain, width)
independentTransforms.value = next
} else {
sharedTransform.value = transformForDomain(nextX, initialDomain, innerWidth.value)
}
const targets = active.independent ? [track] : trackLayouts.value
targets.forEach((target) => {
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 ySpan = source[1] - source[0]
const nextY = clampDomain(
[source[0] + (dy / height) * ySpan, source[1] + (dy / height) * ySpan],
boundary,
)
if (active.independent)
independentYDomains.value = { ...independentYDomains.value, [target.index]: nextY }
else sharedYDomains.value = { ...sharedYDomains.value, [key]: nextY }
})
emit('zoom-change', nextX)
}
function finishViewportDrag(event: PointerEvent) {
const active = selection.value
if (!active || event.pointerId !== active.pointerId) return
updateViewportDrag(event)
active.overlay.releasePointerCapture?.(active.pointerId)
selection.value = null
if (active.mode === 'pan') {
void nextTick(configureZoom)
return
}
const width = Math.abs(active.currentX - active.startX)
if (width < minimumSelectionSize) return
applyBoxZoom(active)
}
function cancelViewportDrag(event?: PointerEvent) {
const active = selection.value
if (!active || (event && event.pointerId !== active.pointerId)) return
active.overlay.releasePointerCapture?.(active.pointerId)
selection.value = null
}
function applyBoxZoom(active: SelectionState) {
const track = trackLayouts.value.find((item) => item.index === active.trackIndex)
if (!track || (active.independent ? !canZoomTrack(track) : !canZoomSharedTracks())) return
const offsetX = active.independent ? 0 : track.left
const left = Math.max(
0,
Math.min(track.width, Math.min(active.startX, active.currentX) - offsetX),
)
const right = Math.max(
0,
Math.min(track.width, Math.max(active.startX, active.currentX) - offsetX),
)
if (right - left < minimumSelectionSize) return
const baseXDomain = active.independent ? resolveInitialTrackDomain(track) : initialXDomain.value
const xDomain = constrainZoomDomain(
[track.xScale.invert(left), track.xScale.invert(right)],
baseXDomain,
)
if (active.independent) {
const next = [...independentTransforms.value]
next[track.index] = transformForDomain(xDomain, baseXDomain, track.width)
independentTransforms.value = next
} else {
sharedTransform.value = transformForDomain(xDomain, baseXDomain, innerWidth.value)
}
const targets = active.independent ? [track] : trackLayouts.value
const yRanges: Record<string, [number, number]> = Object.fromEntries(
targets.map((target) => [
target.series.trackId ?? target.series.id,
target.yScale.domain() as [number, number],
]),
)
emit('zoom-change', xDomain)
const payload: WaveformZoomEndPayload = { start: xDomain[0], end: xDomain[1], gesture: 'box' }
if (active.independent) {
const yDomain = yRanges[track.series.trackId ?? track.series.id]
payload.trackIndex = track.index
payload.seriesIds = track.seriesList.map((series) => series.id)
payload.yStart = yDomain?.[0]
payload.yEnd = yDomain?.[1]
} else if (targets.length === 1) {
const yDomain = Object.values(yRanges)[0]
payload.yStart = yDomain?.[0]
payload.yEnd = yDomain?.[1]
} else payload.yRanges = yRanges
emit('zoom-end', payload)
void nextTick(configureZoom)
}
function resetViewport(trackIndex?: number) {
cancelPendingZoom()
sharedTransform.value = zoomIdentity
independentTransforms.value = chartTracks.value.map(() => zoomIdentity)
cancelViewportDrag()
if (props.displayMode === 'independent' && trackIndex !== undefined) {
const nextTransforms = [...independentTransforms.value]
nextTransforms[trackIndex] = zoomIdentity
independentTransforms.value = nextTransforms
const nextYDomains = { ...independentYDomains.value }
delete nextYDomains[trackIndex]
independentYDomains.value = nextYDomains
} else {
sharedTransform.value = zoomIdentity
independentTransforms.value = chartTracks.value.map(() => zoomIdentity)
sharedYDomains.value = {}
independentYDomains.value = {}
}
clearHover()
editorSeriesOptions.value = []
void nextTick(configureZoom)
}
function requestViewportReset(event: MouseEvent) {
if (!props.zoomable || !isZoomMode.value) return
event.preventDefault()
resetViewport()
emit('zoom-reset')
}
defineExpose({ resetViewport })
function goToPage(page: number) {
const nextPage = Math.min(pageCount.value, Math.max(1, Math.floor(page)))
if (nextPage === currentPage.value) return
@@ -1170,6 +1592,7 @@ function goToPage(page: number) {
cancelAnnotation()
if (props.displayMode === 'independent') {
independentTransforms.value = pagedTracks.value.map(() => zoomIdentity)
independentYDomains.value = {}
}
void nextTick(configureZoom)
emit('page-change', nextPage, pageCount.value)
@@ -1180,6 +1603,9 @@ watch(
innerWidth,
innerHeight,
() => props.zoomable,
() => props.minZoomSpan,
() => props.initialXDomain,
() => props.initialXDomains,
() => props.displayMode,
() => chartTracks.value.length,
() => currentPage.value,
@@ -1195,10 +1621,20 @@ watch(
)
function handleDataReferenceChange() {
const previousPage = currentPage.value
currentPage.value = 1
resetViewport()
if (previousPage !== 1) emit('page-change', 1, pageCount.value)
if (props.displayMode === 'independent') {
const currentTransforms = independentTransforms.value
independentTransforms.value = chartTracks.value.map(
(_track, index) => currentTransforms[index] ?? zoomIdentity,
)
clearHover()
editorSeriesOptions.value = []
void nextTick(configureZoom)
return
}
clearHover()
editorSeriesOptions.value = []
void nextTick(configureZoom)
}
watch(
@@ -1321,6 +1757,8 @@ watch(
)
onMounted(() => {
window.addEventListener('keydown', handleInteractionKeyDown)
window.addEventListener('keyup', handleInteractionKeyUp)
if (!container.value) return
resizeObserver.value = new ResizeObserver(([entry]) => {
observedWidth.value = Math.max(0, entry?.contentRect.width ?? 0)
@@ -1331,6 +1769,8 @@ onMounted(() => {
})
onBeforeUnmount(() => {
window.removeEventListener('keydown', handleInteractionKeyDown)
window.removeEventListener('keyup', handleInteractionKeyUp)
cancelPendingHover()
resizeObserver.value?.disconnect()
clearZoomBindings()
@@ -1345,6 +1785,7 @@ onBeforeUnmount(() => {
:class="[
`waveform-chart--${displayMode}`,
`waveform-chart--interaction-${activeInteractionMode}`,
{ 'waveform-chart--panning': selection?.mode === 'pan' },
]"
:style="containerStyle"
:data-display-mode="displayMode"
@@ -1388,6 +1829,7 @@ onBeforeUnmount(() => {
:height="drawingHeight"
role="img"
:aria-label="hasWaveformData ? '波形折线图' : '暂无波形数据'"
@dblclick="requestViewportReset"
>
<defs>
<clipPath
@@ -1426,6 +1868,9 @@ onBeforeUnmount(() => {
:width="innerWidth"
:height="innerHeight"
@pointermove="handleSharedPointerMove"
@pointerdown="beginSharedViewportDrag"
@pointerup="finishViewportDrag"
@pointercancel="cancelViewportDrag"
@pointerleave="clearHover"
@click="handleAnnotationClick"
@contextmenu="handleAnnotationContextMenu"
@@ -1448,11 +1893,24 @@ onBeforeUnmount(() => {
:y-label="yLabel"
:hovered-point="hoveredSeriesPoints.find((p) => p.trackIndex === track.index)"
@pointer-move="handleIndependentPointerMove($event, track.index)"
@pointer-down="beginViewportDrag($event, track.index, true)"
@pointer-up="finishViewportDrag"
@pointer-cancel="cancelViewportDrag"
@pointer-leave="clearHover"
@click="handleAnnotationClick($event, track.index)"
@contextmenu="handleAnnotationContextMenu($event, track.index)"
/>
<rect
v-if="selectionBox && selection?.mode === 'box'"
class="waveform-chart__zoom-selection"
:x="selectionBox.x"
:y="selectionBox.y"
:width="selectionBox.width"
:height="selectionBox.height"
aria-hidden="true"
/>
<WaveformAnnotationLayer
:annotations="renderedAnnotations"
:visible="annotationsVisible"
@@ -1647,13 +2105,20 @@ onBeforeUnmount(() => {
}
.waveform-chart__overlay.is-zoomable {
cursor: grab;
cursor: crosshair;
}
.waveform-chart__overlay.is-zoomable:active {
.waveform-chart--panning .waveform-chart__overlay.is-zoomable {
cursor: grabbing;
}
.waveform-chart__zoom-selection {
fill: rgb(22 119 255 / 14%);
stroke: #1677ff;
stroke-width: 1;
pointer-events: none;
}
.waveform-chart__overlay.is-annotating {
cursor: crosshair;
}

View File

@@ -66,6 +66,7 @@ function layoutForSeries(
overlayMode: 'single-axis',
independentTransforms: [transform],
sharedZoomDomain: sourceSeries.xDomain,
yDomains: undefined,
timeUnit: 'ms',
rendering,
hideSecondaryLabels: false,
@@ -75,6 +76,42 @@ function layoutForSeries(
}
describe('multi-value Y-axis grouping', () => {
it('uses a configured visible Y domain for axis and series scales', () => {
const source = series('a', 0, 100)
const sourceTrack = track([source])
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 },
displayMode: 'independent',
overlayMode: 'single-axis',
independentTransforms: [zoomIdentity],
sharedZoomDomain: [0, 1],
yDomains: { track: [25, 75] },
timeUnit: 'ms',
rendering: DEFAULT_WAVEFORM_RENDERING_OPTIONS,
hideSecondaryLabels: false,
yAxisLabelX: -50,
showCompactEmptyTracks: false,
})[0]
expect(result?.yScale.domain()).toEqual([25, 75])
expect(result?.seriesPaths[0]?.yScale.domain()).toEqual([25, 75])
})
it('keeps every overlaid series on one axis in single-axis mode', () => {
const groups = buildYAxisSeriesGroups(
track([series('a', 0, 1), series('b', 10, 20)]),

View File

@@ -190,6 +190,9 @@ export interface BuildTrackLayoutsOptions {
overlayMode: WaveformOverlayMode
independentTransforms: ZoomTransform[]
sharedZoomDomain: [number, number]
initialXDomain?: [number, number]
initialXDomains?: Record<string, [number, number]>
yDomains?: Record<string, [number, number]>
timeUnit: 's' | 'ms'
rendering: ResolvedWaveformRenderingOptions
hideSecondaryLabels: boolean
@@ -226,14 +229,23 @@ export function buildTrackLayouts(options: BuildTrackLayoutsOptions): TrackLayou
const series = displayTrack.visibleSeries[0] ?? displayTrack.series[0] ?? emptySeries
const baseXScale =
options.displayMode === 'independent'
? scaleLinear(displayTrack.xDomain, [0, cell.width])
? scaleLinear(
options.initialXDomains?.[displayTrack.id] ??
options.initialXDomains?.[series.id] ??
displayTrack.xDomain,
[0, cell.width],
)
: scaleLinear(options.sharedZoomDomain, [0, cell.width])
const transform =
options.displayMode === 'independent'
? (options.independentTransforms[index] ?? zoomIdentity)
: zoomIdentity
const xScale = transform.rescaleX(baseXScale)
const yAxisGroups = buildYAxisSeriesGroups(displayTrack, options.overlayMode)
const configuredYDomain = options.yDomains?.[displayTrack.id]
const yAxisGroups = buildYAxisSeriesGroups(displayTrack, options.overlayMode).map((group) => ({
...group,
domain: configuredYDomain ?? group.domain,
}))
const sideOffsets = { left: 0, right: 0 }
const yAxes: WaveformYAxisLayout[] = yAxisGroups.map((group) => {
const scale = scaleLinear(group.domain, [cell.plotHeight, 0]).nice()

View File

@@ -41,6 +41,9 @@ interface Props {
interface Emits {
(e: 'pointer-move', event: PointerEvent): void
(e: 'pointer-down', event: PointerEvent): void
(e: 'pointer-up', event: PointerEvent): void
(e: 'pointer-cancel', event: PointerEvent): void
(e: 'pointer-leave'): void
(e: 'click', event: MouseEvent): void
(e: 'contextmenu', event: MouseEvent): void
@@ -409,6 +412,9 @@ watch(
:width="track.width ?? innerWidth"
:height="track.height"
@pointermove="emit('pointer-move', $event)"
@pointerdown="emit('pointer-down', $event)"
@pointerup="emit('pointer-up', $event)"
@pointercancel="emit('pointer-cancel', $event)"
@pointerleave="emit('pointer-leave')"
@click="emit('click', $event)"
@contextmenu="emit('contextmenu', $event)"
@@ -471,6 +477,8 @@ watch(
fill: rgb(22 119 255 / 10%);
font-family: Consolas, Monaco, 'Courier New', monospace;
pointer-events: none;
user-select: none;
-webkit-user-select: none;
}
.waveform-track__overlay {