2026-07-27 15:05:13 +08:00
|
|
|
import {
|
|
|
|
|
scaleLinear,
|
|
|
|
|
select,
|
|
|
|
|
zoom,
|
|
|
|
|
zoomIdentity,
|
|
|
|
|
type D3ZoomEvent,
|
|
|
|
|
type ZoomBehavior,
|
|
|
|
|
type ZoomTransform,
|
|
|
|
|
} from 'd3'
|
|
|
|
|
import type { ComputedRef, ShallowRef } from 'vue'
|
|
|
|
|
|
|
|
|
|
import { hasMinimumVisibleXValues } from '../../core/rendering'
|
|
|
|
|
import { WHEEL_ZOOM_DEBOUNCE_MS, ZOOM_CONSTRAINTS } from '../core/constants'
|
|
|
|
|
import type { TrackLayout } from '../core/types'
|
|
|
|
|
import type { ResolvedWaveformChartProps, WaveformChartEmit } from '../core/waveformChartTypes'
|
|
|
|
|
import { useAnimationFrameThrottle } from '../utils/useAnimationFrameThrottle'
|
|
|
|
|
|
|
|
|
|
interface ZoomContext {
|
|
|
|
|
props: ResolvedWaveformChartProps
|
|
|
|
|
emit: WaveformChartEmit
|
|
|
|
|
svgElement: ShallowRef<SVGSVGElement | undefined>
|
|
|
|
|
sharedOverlayElement: ShallowRef<SVGRectElement | undefined>
|
|
|
|
|
sharedTransform: ShallowRef<ZoomTransform>
|
|
|
|
|
independentTransforms: ShallowRef<ZoomTransform[]>
|
|
|
|
|
trackLayouts: ComputedRef<TrackLayout[]>
|
|
|
|
|
initialXDomain: ComputedRef<[number, number]>
|
|
|
|
|
sharedZoomDomain: ComputedRef<[number, number]>
|
|
|
|
|
innerWidth: ComputedRef<number>
|
|
|
|
|
innerHeight: ComputedRef<number>
|
|
|
|
|
hasChartArea: ComputedRef<boolean>
|
|
|
|
|
isZoomMode: ComputedRef<boolean>
|
2026-07-29 12:12:03 +08:00
|
|
|
isPresentationMode: ComputedRef<boolean>
|
2026-07-27 15:05:13 +08:00
|
|
|
resolveInitialTrackDomain: (track: TrackLayout) => [number, number]
|
|
|
|
|
cancelPendingHover: () => void
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type ZoomGestureKind = 'wheel'
|
|
|
|
|
|
|
|
|
|
export function useWaveformZoom(context: ZoomContext) {
|
|
|
|
|
const {
|
|
|
|
|
props,
|
|
|
|
|
emit,
|
|
|
|
|
svgElement,
|
|
|
|
|
sharedOverlayElement,
|
|
|
|
|
sharedTransform,
|
|
|
|
|
independentTransforms,
|
|
|
|
|
trackLayouts,
|
|
|
|
|
initialXDomain,
|
|
|
|
|
sharedZoomDomain,
|
|
|
|
|
innerWidth,
|
|
|
|
|
innerHeight,
|
|
|
|
|
hasChartArea,
|
|
|
|
|
isZoomMode,
|
2026-07-29 12:12:03 +08:00
|
|
|
isPresentationMode,
|
2026-07-27 15:05:13 +08:00
|
|
|
resolveInitialTrackDomain,
|
|
|
|
|
cancelPendingHover,
|
|
|
|
|
} = context
|
|
|
|
|
const zoomBehaviors = new Map<number | 'shared', ZoomBehavior<SVGRectElement, unknown>>()
|
|
|
|
|
const zoomThrottle = useAnimationFrameThrottle()
|
|
|
|
|
let synchronizingZoomTransform = false
|
|
|
|
|
let pendingSharedZoomTransform: ZoomTransform | null = null
|
|
|
|
|
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>()
|
|
|
|
|
let wheelZoomEndTimer: ReturnType<typeof setTimeout> | undefined
|
|
|
|
|
|
|
|
|
|
const scheduleZoomCommit = () => zoomThrottle.schedule(commitPendingZoom)
|
|
|
|
|
const handleSharedZoom = (event: D3ZoomEvent<SVGRectElement, unknown>) => {
|
2026-07-29 12:12:03 +08:00
|
|
|
if (synchronizingZoomTransform || isPresentationMode.value) return
|
2026-07-27 15:05:13 +08:00
|
|
|
cancelPendingHover()
|
|
|
|
|
pendingSharedZoomTransform = event.transform
|
|
|
|
|
pendingSharedZoomGesture = 'wheel'
|
|
|
|
|
scheduleZoomCommit()
|
|
|
|
|
scheduleWheelZoomEnd()
|
|
|
|
|
}
|
|
|
|
|
const handleIndependentZoom = (
|
|
|
|
|
event: D3ZoomEvent<SVGRectElement, unknown>,
|
|
|
|
|
trackIndex: number,
|
|
|
|
|
) => {
|
2026-07-29 12:12:03 +08:00
|
|
|
if (synchronizingZoomTransform || isPresentationMode.value) return
|
2026-07-27 15:05:13 +08:00
|
|
|
cancelPendingHover()
|
|
|
|
|
pendingIndependentZoomTransforms.set(trackIndex, event.transform)
|
|
|
|
|
pendingIndependentZoomGestures.set(trackIndex, 'wheel')
|
|
|
|
|
scheduleZoomCommit()
|
|
|
|
|
scheduleWheelZoomEnd()
|
|
|
|
|
}
|
|
|
|
|
function commitPendingZoom() {
|
2026-07-29 12:12:03 +08:00
|
|
|
if (isPresentationMode.value) {
|
|
|
|
|
cancelPendingZoom()
|
|
|
|
|
return
|
|
|
|
|
}
|
2026-07-27 15:05:13 +08:00
|
|
|
if (pendingSharedZoomTransform) {
|
|
|
|
|
const transform = pendingSharedZoomTransform
|
|
|
|
|
pendingSharedZoomTransform = null
|
|
|
|
|
lastSharedZoomGesture = pendingSharedZoomGesture
|
|
|
|
|
pendingSharedZoomGesture = null
|
|
|
|
|
sharedTransform.value = transform
|
|
|
|
|
const domain = transform
|
|
|
|
|
.rescaleX(scaleLinear(initialXDomain.value, [0, innerWidth.value]))
|
|
|
|
|
.domain()
|
|
|
|
|
emit('zoom-change', [domain[0], domain[1]])
|
|
|
|
|
}
|
|
|
|
|
if (!pendingIndependentZoomTransforms.size) return
|
|
|
|
|
const nextTransforms = [...independentTransforms.value]
|
|
|
|
|
const changedTrackIndexes = Array.from(pendingIndependentZoomTransforms.keys())
|
|
|
|
|
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
|
|
|
|
|
const tracksByIndex = new Map(trackLayouts.value.map((track) => [track.index, track]))
|
|
|
|
|
changedTrackIndexes.forEach((trackIndex) => {
|
|
|
|
|
const track = tracksByIndex.get(trackIndex)
|
|
|
|
|
if (!track) return
|
|
|
|
|
const domain = track.xScale.domain()
|
|
|
|
|
emit('zoom-change', [domain[0], domain[1]])
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
const emitZoomEnd = () => {
|
|
|
|
|
if (props.displayMode === 'independent') {
|
|
|
|
|
const tracksByIndex = new Map(trackLayouts.value.map((track) => [track.index, track]))
|
|
|
|
|
lastZoomedTrackIndexes.forEach((trackIndex) => {
|
|
|
|
|
if (lastIndependentZoomGestures.get(trackIndex) !== 'wheel') return
|
|
|
|
|
const track = tracksByIndex.get(trackIndex)
|
|
|
|
|
if (!track) return
|
|
|
|
|
const domain = track.xScale.domain() as [number, number]
|
|
|
|
|
const yDomain = track.yScale.domain()
|
|
|
|
|
emit('zoom-end', {
|
|
|
|
|
start: domain[0],
|
|
|
|
|
end: domain[1],
|
|
|
|
|
yStart: yDomain[0],
|
|
|
|
|
yEnd: yDomain[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
|
|
|
|
|
const visibleTracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
|
|
|
|
|
if (visibleTracks.length === 1) {
|
|
|
|
|
const yDomain = visibleTracks[0]?.yScale.domain()
|
|
|
|
|
emit('zoom-end', {
|
|
|
|
|
start: domain[0],
|
|
|
|
|
end: domain[1],
|
|
|
|
|
yStart: yDomain?.[0],
|
|
|
|
|
yEnd: yDomain?.[1],
|
|
|
|
|
gesture: 'wheel',
|
|
|
|
|
})
|
|
|
|
|
} else {
|
|
|
|
|
emit('zoom-end', {
|
|
|
|
|
start: domain[0],
|
|
|
|
|
end: domain[1],
|
|
|
|
|
yRanges: Object.fromEntries(
|
|
|
|
|
visibleTracks.map((track) => [
|
|
|
|
|
track.series.trackId ?? track.series.id,
|
|
|
|
|
track.yScale.domain() as [number, number],
|
|
|
|
|
]),
|
|
|
|
|
),
|
|
|
|
|
gesture: 'wheel',
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
lastSharedZoomGesture = null
|
|
|
|
|
}
|
|
|
|
|
const flushPendingZoom = () => {
|
|
|
|
|
zoomThrottle.flush()
|
|
|
|
|
commitPendingZoom()
|
|
|
|
|
if (lastSharedZoomGesture === 'wheel' || lastIndependentZoomGestures.size) return
|
|
|
|
|
emitZoomEnd()
|
|
|
|
|
}
|
|
|
|
|
function scheduleWheelZoomEnd() {
|
|
|
|
|
if (wheelZoomEndTimer !== undefined) clearTimeout(wheelZoomEndTimer)
|
|
|
|
|
wheelZoomEndTimer = setTimeout(() => {
|
|
|
|
|
wheelZoomEndTimer = undefined
|
|
|
|
|
zoomThrottle.flush()
|
|
|
|
|
commitPendingZoom()
|
|
|
|
|
emitZoomEnd()
|
|
|
|
|
}, WHEEL_ZOOM_DEBOUNCE_MS)
|
|
|
|
|
}
|
|
|
|
|
const cancelPendingZoom = () => {
|
|
|
|
|
pendingSharedZoomTransform = null
|
|
|
|
|
pendingSharedZoomGesture = null
|
|
|
|
|
lastSharedZoomGesture = null
|
|
|
|
|
pendingIndependentZoomTransforms.clear()
|
|
|
|
|
pendingIndependentZoomGestures.clear()
|
|
|
|
|
lastZoomedTrackIndexes.clear()
|
|
|
|
|
lastIndependentZoomGestures.clear()
|
|
|
|
|
zoomThrottle.cancel()
|
|
|
|
|
if (wheelZoomEndTimer !== undefined) {
|
|
|
|
|
clearTimeout(wheelZoomEndTimer)
|
|
|
|
|
wheelZoomEndTimer = undefined
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
const clearZoomBindings = () => {
|
|
|
|
|
cancelPendingZoom()
|
|
|
|
|
svgElement.value
|
|
|
|
|
?.querySelectorAll<SVGRectElement>('.waveform-chart__overlay')
|
|
|
|
|
.forEach((overlay) => select(overlay).on('.zoom', null))
|
|
|
|
|
zoomBehaviors.clear()
|
|
|
|
|
}
|
|
|
|
|
const resolveMaximumZoomScale = (domain: [number, number]): number => {
|
|
|
|
|
const minZoomSpan = props.minZoomSpan
|
|
|
|
|
if (!Number.isFinite(minZoomSpan) || (minZoomSpan ?? 0) <= 0) {
|
|
|
|
|
return ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE
|
|
|
|
|
}
|
|
|
|
|
const domainSpan = Math.abs(domain[1] - domain[0])
|
|
|
|
|
if (!Number.isFinite(domainSpan) || domainSpan <= 0) return ZOOM_CONSTRAINTS.MIN_SCALE
|
|
|
|
|
return Math.min(
|
|
|
|
|
ZOOM_CONSTRAINTS.DEFAULT_MAX_SCALE,
|
|
|
|
|
Math.max(ZOOM_CONSTRAINTS.MIN_SCALE, domainSpan / (minZoomSpan ?? domainSpan)),
|
|
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
const canZoomTrack = (track: TrackLayout): boolean =>
|
|
|
|
|
hasMinimumVisibleXValues(
|
|
|
|
|
track.seriesList,
|
|
|
|
|
track.xScale.domain() as [number, number],
|
|
|
|
|
Number(props.minVisiblePoints),
|
|
|
|
|
)
|
|
|
|
|
const canZoomSharedTracks = (): boolean => {
|
|
|
|
|
const tracks = trackLayouts.value.filter((track) => track.hasVisibleSeries)
|
|
|
|
|
return tracks.length > 0 && tracks.every(canZoomTrack)
|
|
|
|
|
}
|
|
|
|
|
const configureZoom = () => {
|
|
|
|
|
clearZoomBindings()
|
2026-07-29 12:12:03 +08:00
|
|
|
if (
|
|
|
|
|
isPresentationMode.value ||
|
|
|
|
|
!props.zoomable ||
|
|
|
|
|
!isZoomMode.value ||
|
|
|
|
|
!hasChartArea.value ||
|
|
|
|
|
!trackLayouts.value.length
|
|
|
|
|
) {
|
2026-07-27 15:05:13 +08:00
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if (props.displayMode === 'independent') {
|
|
|
|
|
trackLayouts.value.forEach((track) => {
|
|
|
|
|
const overlay = svgElement.value?.querySelector<SVGRectElement>(
|
|
|
|
|
`[data-independent-overlay-index="${track.index}"]`,
|
|
|
|
|
)
|
|
|
|
|
if (!overlay) return
|
|
|
|
|
const dataDomain = resolveInitialTrackDomain(track)
|
|
|
|
|
const behavior = zoom<SVGRectElement, unknown>()
|
|
|
|
|
.filter(
|
|
|
|
|
(event) =>
|
|
|
|
|
event.type === 'wheel' && (event as WheelEvent).deltaY < 0 && canZoomTrack(track),
|
|
|
|
|
)
|
|
|
|
|
.scaleExtent([1, resolveMaximumZoomScale(dataDomain)])
|
|
|
|
|
.extent([
|
|
|
|
|
[0, 0],
|
|
|
|
|
[track.width, track.height],
|
|
|
|
|
])
|
|
|
|
|
.translateExtent([
|
|
|
|
|
[0, 0],
|
|
|
|
|
[track.width, track.height],
|
|
|
|
|
])
|
|
|
|
|
.on('zoom', (event) => handleIndependentZoom(event, track.index))
|
|
|
|
|
.on('end', flushPendingZoom)
|
|
|
|
|
zoomBehaviors.set(track.index, behavior)
|
|
|
|
|
synchronizingZoomTransform = true
|
|
|
|
|
try {
|
|
|
|
|
select(overlay)
|
|
|
|
|
.call(behavior)
|
|
|
|
|
.on('dblclick.zoom', null)
|
|
|
|
|
.call(behavior.transform, independentTransforms.value[track.index] ?? zoomIdentity)
|
|
|
|
|
} finally {
|
|
|
|
|
synchronizingZoomTransform = false
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
const overlay = sharedOverlayElement.value
|
|
|
|
|
if (!overlay) return
|
|
|
|
|
const behavior = zoom<SVGRectElement, unknown>()
|
|
|
|
|
.filter(
|
|
|
|
|
(event) =>
|
|
|
|
|
event.type === 'wheel' && (event as WheelEvent).deltaY < 0 && canZoomSharedTracks(),
|
|
|
|
|
)
|
|
|
|
|
.scaleExtent([1, resolveMaximumZoomScale(initialXDomain.value)])
|
|
|
|
|
.extent([
|
|
|
|
|
[0, 0],
|
|
|
|
|
[innerWidth.value, innerHeight.value],
|
|
|
|
|
])
|
|
|
|
|
.translateExtent([
|
|
|
|
|
[0, 0],
|
|
|
|
|
[innerWidth.value, innerHeight.value],
|
|
|
|
|
])
|
|
|
|
|
.on('zoom', handleSharedZoom)
|
|
|
|
|
.on('end', flushPendingZoom)
|
|
|
|
|
zoomBehaviors.set('shared', behavior)
|
|
|
|
|
synchronizingZoomTransform = true
|
|
|
|
|
try {
|
|
|
|
|
select(overlay)
|
|
|
|
|
.call(behavior)
|
|
|
|
|
.on('dblclick.zoom', null)
|
|
|
|
|
.call(behavior.transform, sharedTransform.value)
|
|
|
|
|
} finally {
|
|
|
|
|
synchronizingZoomTransform = false
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
cancelPendingZoom,
|
|
|
|
|
clearZoomBindings,
|
|
|
|
|
canZoomTrack,
|
|
|
|
|
canZoomSharedTracks,
|
|
|
|
|
configureZoom,
|
|
|
|
|
}
|
|
|
|
|
}
|