fix(trajectory): contain timeline wheel zoom
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import {
|
||||
memo, useEffect, useMemo, useRef, useState, type CSSProperties, type KeyboardEvent,
|
||||
type PointerEvent, type WheelEvent,
|
||||
type PointerEvent,
|
||||
} from 'react'
|
||||
import type { TrajectoryTurnModel } from './layout.ts'
|
||||
import {
|
||||
@@ -117,6 +117,8 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
anchorClientX: number
|
||||
recordIndex: number | null
|
||||
} | null>(null)
|
||||
const rootRef = useRef<HTMLElement | null>(null)
|
||||
const trackRef = useRef<HTMLDivElement | null>(null)
|
||||
const [draft, setDraft] = useState<TrajectoryTimeRange | null>(null)
|
||||
const [hover, setHover] = useState<HoverPoint | null>(null)
|
||||
const [viewport, setViewport] = useState<TrajectoryTimeRange | null>(null)
|
||||
@@ -189,10 +191,42 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
: rangeFraction(draft, domainStart, domainDuration)
|
||||
const visibleRange = draftFraction ?? committed
|
||||
const activeRange = draft ?? range
|
||||
useEffect(() => {
|
||||
const root = rootRef.current
|
||||
if (root === null) return
|
||||
const onWheel = (event: globalThis.WheelEvent): void => {
|
||||
event.preventDefault()
|
||||
const track = trackRef.current
|
||||
if (track === null || model === null) return
|
||||
setAnimateViewport(false)
|
||||
const rect = track.getBoundingClientRect()
|
||||
const anchorFraction =
|
||||
clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
|
||||
const nextDuration = Math.min(
|
||||
fullDuration,
|
||||
Math.max(
|
||||
Math.min(mode === 'sequence' ? MINIMUM_ZOOM_OPERATIONS : 20, fullDuration),
|
||||
domainDuration * Math.exp(event.deltaY * 0.0015),
|
||||
),
|
||||
)
|
||||
if (nextDuration >= fullDuration * 0.999) {
|
||||
setViewport(null)
|
||||
return
|
||||
}
|
||||
const anchorTime = domainStart + anchorFraction * domainDuration
|
||||
const nextStart = Math.min(
|
||||
Math.max(anchorTime - anchorFraction * nextDuration, model.start),
|
||||
model.end - nextDuration,
|
||||
)
|
||||
setViewport({ start: nextStart, end: nextStart + nextDuration })
|
||||
}
|
||||
root.addEventListener('wheel', onWheel, { passive: false })
|
||||
return () => { root.removeEventListener('wheel', onWheel) }
|
||||
}, [domainDuration, domainStart, fullDuration, mode, model])
|
||||
|
||||
if (model === null) {
|
||||
return (
|
||||
<section className={css.root} aria-label="Trajectory timeline">
|
||||
<section ref={rootRef} className={css.root} aria-label="Trajectory timeline">
|
||||
<div className={css.plot}>
|
||||
<LaneLabels />
|
||||
<div className={css.track}>
|
||||
@@ -339,36 +373,12 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
setHover(null)
|
||||
}
|
||||
|
||||
const onWheel = (event: WheelEvent<HTMLDivElement>) => {
|
||||
event.preventDefault()
|
||||
setAnimateViewport(false)
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
const anchorFraction =
|
||||
clampFraction((event.clientX - rect.left) / Math.max(1, rect.width))
|
||||
const nextDuration = Math.min(
|
||||
fullDuration,
|
||||
Math.max(
|
||||
Math.min(mode === 'sequence' ? MINIMUM_ZOOM_OPERATIONS : 20, fullDuration),
|
||||
domainDuration * Math.exp(event.deltaY * 0.0015),
|
||||
),
|
||||
)
|
||||
if (nextDuration >= fullDuration * 0.999) {
|
||||
setViewport(null)
|
||||
return
|
||||
}
|
||||
const anchorTime = domainStart + anchorFraction * domainDuration
|
||||
const nextStart = Math.min(
|
||||
Math.max(anchorTime - anchorFraction * nextDuration, model.start),
|
||||
model.end - nextDuration,
|
||||
)
|
||||
setViewport({ start: nextStart, end: nextStart + nextDuration })
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={css.root} aria-label="Trajectory timeline">
|
||||
<section ref={rootRef} className={css.root} aria-label="Trajectory timeline">
|
||||
<div className={css.plot}>
|
||||
<LaneLabels />
|
||||
<div
|
||||
ref={trackRef}
|
||||
className={css.track}
|
||||
aria-label="Timeline overview; drag horizontally to focus events"
|
||||
tabIndex={0}
|
||||
@@ -384,7 +394,6 @@ export const TrajectoryTimeline = memo(function TrajectoryTimeline({
|
||||
event.preventDefault()
|
||||
onRangeChange(null)
|
||||
}}
|
||||
onWheel={onWheel}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault()
|
||||
setAnimateViewport(false)
|
||||
|
||||
@@ -391,6 +391,28 @@ describe('timeline projection', () => {
|
||||
}],
|
||||
}] satisfies readonly TrajectoryTurnModel[]
|
||||
|
||||
it('cancels native scrolling across the timeline while zooming', () => {
|
||||
render(
|
||||
<TrajectoryTimeline
|
||||
turns={longTurns}
|
||||
mode="sequence"
|
||||
range={null}
|
||||
onRangeChange={vi.fn()}
|
||||
/>,
|
||||
)
|
||||
const plot = screen.getByLabelText('Timeline overview; drag horizontally to focus events')
|
||||
vi.spyOn(plot, 'getBoundingClientRect').mockReturnValue({
|
||||
x: 44, y: 0, left: 44, top: 0, right: 144, bottom: 50, width: 100, height: 50,
|
||||
toJSON: () => ({}),
|
||||
})
|
||||
|
||||
expect(fireEvent.wheel(plot, { clientX: 94, deltaY: -100 })).toBe(false)
|
||||
expect(fireEvent.wheel(screen.getByText('Input'), {
|
||||
clientX: 20,
|
||||
deltaY: -100,
|
||||
})).toBe(false)
|
||||
})
|
||||
|
||||
it('pans the zoomed viewport only far enough to reveal a newly selected record', async () => {
|
||||
const onRangeChange = vi.fn()
|
||||
const view = render(
|
||||
|
||||
Reference in New Issue
Block a user