fix(trajectory): contain timeline wheel zoom

This commit is contained in:
_Kerman
2026-07-31 11:28:01 +08:00
parent 37a127af0c
commit 5255eb6982
2 changed files with 60 additions and 29 deletions

View File

@@ -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)

View File

@@ -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(