Timeline improvements (#16429)

* virtualize event segments

* use virtual segments in event review timeline

* add segmentkey to props

* virtualize motion segments

* use virtual segments in motion review timeline

* update draggable element hook to use only math

* timeline zooming hook

* add zooming to event review timeline

* update playground

* zoomable timeline on recording view

* consolidate divs in summary timeline

* only calculate motion data for visible motion segments

* use swr loading state

* fix motion only

* keep handlebar centered when zooming

* zoom animations

* clean up

* ensure motion only checks both halves of segment

* prevent handlebar jump when using motion only mode
This commit is contained in:
Josh Hawkins
2025-02-09 14:13:32 -07:00
committed by GitHub
parent 1f89844c67
commit cc2dbdcb44
17 changed files with 1157 additions and 515 deletions
@@ -1,9 +1,22 @@
import { useCallback, useMemo, useRef, RefObject } from "react";
import MotionSegment from "./MotionSegment";
import React, {
useCallback,
useMemo,
useRef,
RefObject,
useEffect,
} from "react";
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { MotionData, ReviewSegment, ReviewSeverity } from "@/types/review";
import {
MotionData,
ReviewSegment,
TimelineZoomDirection,
} from "@/types/review";
import ReviewTimeline from "./ReviewTimeline";
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
import {
VirtualizedMotionSegments,
VirtualizedMotionSegmentsRef,
} from "./VirtualizedMotionSegments";
export type MotionReviewTimelineProps = {
segmentDuration: number;
@@ -25,11 +38,12 @@ export type MotionReviewTimelineProps = {
setExportEndTime?: React.Dispatch<React.SetStateAction<number>>;
events: ReviewSegment[];
motion_events: MotionData[];
severityType: ReviewSeverity;
contentRef: RefObject<HTMLDivElement>;
timelineRef?: RefObject<HTMLDivElement>;
onHandlebarDraggingChange?: (isDragging: boolean) => void;
dense?: boolean;
isZooming: boolean;
zoomDirection: TimelineZoomDirection;
};
export function MotionReviewTimeline({
@@ -56,9 +70,12 @@ export function MotionReviewTimeline({
timelineRef,
onHandlebarDraggingChange,
dense = false,
isZooming,
zoomDirection,
}: MotionReviewTimelineProps) {
const internalTimelineRef = useRef<HTMLDivElement>(null);
const selectedTimelineRef = timelineRef || internalTimelineRef;
const virtualizedSegmentsRef = useRef<VirtualizedMotionSegmentsRef>(null);
const timelineDuration = useMemo(
() => timelineStart - timelineEnd + 4 * segmentDuration,
@@ -80,90 +97,75 @@ export function MotionReviewTimeline({
motion_events,
);
// Generate segments for the timeline
const generateSegments = useCallback(() => {
const segmentTimes = useMemo(() => {
const segments = [];
let segmentTime = timelineStartAligned;
while (segmentTime >= timelineStartAligned - timelineDuration) {
const motionStart = segmentTime;
const motionEnd = motionStart + segmentDuration;
for (let i = 0; i < Math.ceil(timelineDuration / segmentDuration); i++) {
if (!motionOnly) {
segments.push(segmentTime);
} else {
const motionStart = segmentTime;
const motionEnd = motionStart + segmentDuration;
const overlappingReviewItems = events.some(
(item) =>
(item.start_time >= motionStart && item.start_time < motionEnd) ||
((item.end_time ?? timelineStart) > motionStart &&
(item.end_time ?? timelineStart) <= motionEnd) ||
(item.start_time <= motionStart &&
(item.end_time ?? timelineStart) >= motionEnd),
);
const firstHalfMotionValue = getMotionSegmentValue(motionStart);
const secondHalfMotionValue = getMotionSegmentValue(
motionStart + segmentDuration / 2,
);
const firstHalfMotionValue = getMotionSegmentValue(motionStart);
const secondHalfMotionValue = getMotionSegmentValue(
motionStart + segmentDuration / 2,
);
const segmentMotion =
firstHalfMotionValue > 0 || secondHalfMotionValue > 0;
const overlappingReviewItems = events.some(
(item) =>
(item.start_time >= motionStart && item.start_time < motionEnd) ||
((item.end_time ?? timelineStart) > motionStart &&
(item.end_time ?? timelineStart) <= motionEnd) ||
(item.start_time <= motionStart &&
(item.end_time ?? timelineStart) >= motionEnd),
);
if ((!segmentMotion || overlappingReviewItems) && motionOnly) {
// exclude segment if necessary when in motion only mode
segmentTime -= segmentDuration;
continue;
const segmentMotion =
firstHalfMotionValue > 0 || secondHalfMotionValue > 0;
if (segmentMotion && !overlappingReviewItems) {
segments.push(segmentTime);
}
}
segments.push(
<MotionSegment
key={segmentTime}
events={events}
firstHalfMotionValue={firstHalfMotionValue}
secondHalfMotionValue={secondHalfMotionValue}
segmentDuration={segmentDuration}
segmentTime={segmentTime}
timestampSpread={timestampSpread}
motionOnly={motionOnly}
showMinimap={showMinimap}
minimapStartTime={minimapStartTime}
minimapEndTime={minimapEndTime}
setHandlebarTime={setHandlebarTime}
dense={dense}
/>,
);
segmentTime -= segmentDuration;
}
return segments;
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [
segmentDuration,
timestampSpread,
timelineStartAligned,
segmentDuration,
timelineDuration,
showMinimap,
minimapStartTime,
minimapEndTime,
events,
motion_events,
motionOnly,
getMotionSegmentValue,
events,
timelineStart,
]);
const segments = useMemo(
() => generateSegments(),
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
[
segmentDuration,
timestampSpread,
timelineStartAligned,
timelineDuration,
showMinimap,
minimapStartTime,
minimapEndTime,
events,
motion_events,
motionOnly,
],
const scrollToSegment = useCallback(
(segmentTime: number, ifNeeded?: boolean, behavior?: ScrollBehavior) => {
if (virtualizedSegmentsRef.current) {
virtualizedSegmentsRef.current.scrollToSegment(
segmentTime,
ifNeeded,
behavior,
);
}
},
[],
);
// keep handlebar centered when zooming
useEffect(() => {
setTimeout(() => {
scrollToSegment(
alignStartDateToTimeline(handlebarTime ?? timelineStart),
true,
"auto",
);
}, 0);
// we only want to scroll when zooming level changes
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [segmentDuration]);
return (
<ReviewTimeline
timelineRef={selectedTimelineRef}
@@ -183,8 +185,28 @@ export function MotionReviewTimeline({
setExportEndTime={setExportEndTime}
timelineCollapsed={motionOnly}
dense={dense}
segments={segmentTimes}
scrollToSegment={scrollToSegment}
isZooming={isZooming}
zoomDirection={zoomDirection}
>
{segments}
<VirtualizedMotionSegments
ref={virtualizedSegmentsRef}
timelineRef={selectedTimelineRef}
segments={segmentTimes}
events={events}
motion_events={motion_events}
segmentDuration={segmentDuration}
timestampSpread={timestampSpread}
showMinimap={showMinimap}
minimapStartTime={minimapStartTime}
minimapEndTime={minimapEndTime}
contentRef={contentRef}
setHandlebarTime={setHandlebarTime}
dense={dense}
motionOnly={motionOnly}
getMotionSegmentValue={getMotionSegmentValue}
/>
</ReviewTimeline>
);
}