refactor(chart): enforce 400-line source limit
Some checks failed
Package component / package (push) Failing after 4m19s
Some checks failed
Package component / package (push) Failing after 4m19s
This commit is contained in:
338
src/components/interaction/useWaveformViewport.ts
Normal file
338
src/components/interaction/useWaveformViewport.ts
Normal file
@@ -0,0 +1,338 @@
|
||||
import { pointer, scaleLinear, zoomIdentity, type ZoomTransform } from 'd3'
|
||||
import { computed, nextTick, type ComputedRef, type Ref, type ShallowRef } from 'vue'
|
||||
|
||||
import { MINIMUM_SELECTION_SIZE } from '../core/constants'
|
||||
import type { DisplayTrack, TrackLayout } from '../core/types'
|
||||
import type {
|
||||
ResolvedWaveformChartProps,
|
||||
ViewportSelectionState,
|
||||
WaveformChartEmit,
|
||||
} from '../core/waveformChartTypes'
|
||||
import type { AnnotationSeriesCandidate } from '../annotation'
|
||||
|
||||
interface ViewportContext {
|
||||
props: ResolvedWaveformChartProps
|
||||
emit: WaveformChartEmit
|
||||
selection: Ref<ViewportSelectionState | null>
|
||||
spacePressed: Ref<boolean>
|
||||
trackLayouts: ComputedRef<TrackLayout[]>
|
||||
chartTracks: ComputedRef<DisplayTrack[]>
|
||||
initialXDomain: ComputedRef<[number, number]>
|
||||
innerWidth: ComputedRef<number>
|
||||
innerHeight: ComputedRef<number>
|
||||
sharedOverlayElement: ShallowRef<SVGRectElement | undefined>
|
||||
sharedTransform: ShallowRef<ZoomTransform>
|
||||
independentTransforms: ShallowRef<ZoomTransform[]>
|
||||
sharedYDomains: Ref<Record<string, [number, number]>>
|
||||
independentYDomains: Ref<Record<number, [number, number]>>
|
||||
isZoomMode: ComputedRef<boolean>
|
||||
editorSeriesOptions: Ref<AnnotationSeriesCandidate[]>
|
||||
resolveInitialTrackDomain: (track: TrackLayout) => [number, number]
|
||||
canZoomTrack: (track: TrackLayout) => boolean
|
||||
canZoomSharedTracks: () => boolean
|
||||
configureZoom: () => void
|
||||
cancelPendingZoom: () => void
|
||||
clearHover: () => void
|
||||
resolveTrackAtPointer: (pointerX: number, pointerY: number) => TrackLayout | undefined
|
||||
}
|
||||
|
||||
export function useWaveformViewport(context: ViewportContext) {
|
||||
const {
|
||||
props,
|
||||
emit,
|
||||
selection,
|
||||
spacePressed,
|
||||
trackLayouts,
|
||||
chartTracks,
|
||||
initialXDomain,
|
||||
innerWidth,
|
||||
innerHeight,
|
||||
sharedOverlayElement,
|
||||
sharedTransform,
|
||||
independentTransforms,
|
||||
sharedYDomains,
|
||||
independentYDomains,
|
||||
isZoomMode,
|
||||
editorSeriesOptions,
|
||||
resolveInitialTrackDomain,
|
||||
canZoomTrack,
|
||||
canZoomSharedTracks,
|
||||
configureZoom,
|
||||
cancelPendingZoom,
|
||||
clearHover,
|
||||
resolveTrackAtPointer,
|
||||
} = context
|
||||
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),
|
||||
}
|
||||
})
|
||||
const 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)
|
||||
}
|
||||
const 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
|
||||
}
|
||||
const 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]
|
||||
}
|
||||
const 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
|
||||
}
|
||||
const currentYDomains = (): Record<string, [number, number]> =>
|
||||
Object.fromEntries(
|
||||
trackLayouts.value.map((track) => [
|
||||
track.series.trackId ?? track.series.id,
|
||||
track.yScale.domain() as [number, number],
|
||||
]),
|
||||
)
|
||||
const beginViewportDrag = (event: PointerEvent, trackIndex: number, independent: boolean) => {
|
||||
const panRequested = props.pannable && spacePressed.value
|
||||
if ((!props.zoomable && !panRequested) || !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: panRequested ? 'pan' : 'box',
|
||||
xDomain: track.xScale.domain() as [number, number],
|
||||
yDomains: currentYDomains(),
|
||||
}
|
||||
overlay.setPointerCapture?.(event.pointerId)
|
||||
clearHover()
|
||||
event.preventDefault()
|
||||
}
|
||||
const 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)
|
||||
}
|
||||
const applyPan = (active: ViewportSelectionState, 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 sourceXDomain = active.independent
|
||||
? resolveInitialTrackDomain(track)
|
||||
: initialXDomain.value
|
||||
const nextX = clampDomain(
|
||||
[active.xDomain[0] - (dx / width) * xSpan, active.xDomain[1] - (dx / width) * xSpan],
|
||||
sourceXDomain,
|
||||
)
|
||||
if (active.independent) {
|
||||
const nextTransforms = [...independentTransforms.value]
|
||||
nextTransforms[track.index] = transformForDomain(nextX, sourceXDomain, width)
|
||||
independentTransforms.value = nextTransforms
|
||||
} else {
|
||||
sharedTransform.value = transformForDomain(nextX, sourceXDomain, innerWidth.value)
|
||||
}
|
||||
const targets = active.independent ? [track] : trackLayouts.value
|
||||
const nextIndependentDomains = { ...independentYDomains.value }
|
||||
const nextSharedDomains = { ...sharedYDomains.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) nextIndependentDomains[target.index] = nextY
|
||||
else nextSharedDomains[key] = nextY
|
||||
})
|
||||
if (active.independent) independentYDomains.value = nextIndependentDomains
|
||||
else sharedYDomains.value = nextSharedDomains
|
||||
emit('zoom-change', nextX)
|
||||
}
|
||||
const 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()
|
||||
}
|
||||
const cancelViewportDrag = (event?: PointerEvent) => {
|
||||
const active = selection.value
|
||||
if (!active || (event && event.pointerId !== active.pointerId)) return
|
||||
active.overlay.releasePointerCapture?.(active.pointerId)
|
||||
selection.value = null
|
||||
}
|
||||
const applyBoxZoom = (active: ViewportSelectionState) => {
|
||||
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 < MINIMUM_SELECTION_SIZE) 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 = Object.fromEntries(
|
||||
targets.map((target) => [
|
||||
target.series.trackId ?? target.series.id,
|
||||
target.yScale.domain() as [number, number],
|
||||
]),
|
||||
)
|
||||
emit('zoom-change', xDomain)
|
||||
if (active.independent) {
|
||||
const yDomain = yRanges[track.series.trackId ?? track.series.id]
|
||||
emit('zoom-end', {
|
||||
start: xDomain[0],
|
||||
end: xDomain[1],
|
||||
trackIndex: track.index,
|
||||
seriesIds: track.seriesList.map((series) => series.id),
|
||||
yStart: yDomain?.[0],
|
||||
yEnd: yDomain?.[1],
|
||||
gesture: 'box',
|
||||
})
|
||||
} else if (targets.length === 1) {
|
||||
const yDomain = Object.values(yRanges)[0]
|
||||
emit('zoom-end', {
|
||||
start: xDomain[0],
|
||||
end: xDomain[1],
|
||||
yStart: yDomain?.[0],
|
||||
yEnd: yDomain?.[1],
|
||||
gesture: 'box',
|
||||
})
|
||||
} else {
|
||||
emit('zoom-end', { start: xDomain[0], end: xDomain[1], yRanges, gesture: 'box' })
|
||||
}
|
||||
void nextTick(configureZoom)
|
||||
}
|
||||
const 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
|
||||
}
|
||||
if (Math.abs(active.currentX - active.startX) >= MINIMUM_SELECTION_SIZE) {
|
||||
applyBoxZoom(active)
|
||||
}
|
||||
}
|
||||
const resetViewport = (trackIndex?: number) => {
|
||||
cancelPendingZoom()
|
||||
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)
|
||||
}
|
||||
const requestViewportReset = (event: MouseEvent) => {
|
||||
if (!props.zoomable || !isZoomMode.value) return
|
||||
event.preventDefault()
|
||||
resetViewport()
|
||||
emit('zoom-reset')
|
||||
}
|
||||
|
||||
return {
|
||||
selectionBox,
|
||||
beginViewportDrag,
|
||||
beginSharedViewportDrag,
|
||||
updateViewportDrag,
|
||||
finishViewportDrag,
|
||||
cancelViewportDrag,
|
||||
resetViewport,
|
||||
requestViewportReset,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user