virtualize motion segments

This commit is contained in:
Josh Hawkins 2024-11-29 20:18:17 -06:00
parent eec237e4c3
commit dd37958d40
2 changed files with 210 additions and 53 deletions

View File

@ -1,14 +1,7 @@
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils";
import { ReviewSegment } from "@/types/review";
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { MinimapBounds, Tick, Timestamp } from "./segment-metadata";
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
import { isMobile } from "react-device-detect";
@ -27,6 +20,7 @@ type MotionSegmentProps = {
minimapStartTime?: number;
minimapEndTime?: number;
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void;
dense: boolean;
};
@ -42,6 +36,7 @@ export function MotionSegment({
minimapStartTime,
minimapEndTime,
setHandlebarTime,
scrollToSegment,
dense,
}: MotionSegmentProps) {
const severityType = "all";
@ -72,7 +67,10 @@ export function MotionSegment({
);
const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]);
const segmentKey = useMemo(() => segmentTime, [segmentTime]);
const segmentKey = useMemo(
() => `${segmentTime}_${segmentDuration}`,
[segmentTime, segmentDuration],
);
const maxSegmentWidth = useMemo(() => {
return isMobile ? 30 : 50;
@ -122,10 +120,7 @@ export function MotionSegment({
// Check if the first segment is out of view
const firstSegment = firstMinimapSegmentRef.current;
if (firstSegment && showMinimap && isFirstSegmentInMinimap) {
scrollIntoView(firstSegment, {
scrollMode: "if-needed",
behavior: "smooth",
});
scrollToSegment(alignedMinimapStartTime);
}
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
@ -163,44 +158,6 @@ export function MotionSegment({
}
}, [segmentTime, setHandlebarTime]);
const [segmentRendered, setSegmentRendered] = useState(false);
const segmentObserverRef = useRef<IntersectionObserver | null>(null);
const segmentRef = useRef(null);
useEffect(() => {
const segmentObserver = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && !segmentRendered) {
setSegmentRendered(true);
}
},
{ threshold: 0 },
);
if (segmentRef.current) {
segmentObserver.observe(segmentRef.current);
}
segmentObserverRef.current = segmentObserver;
return () => {
if (segmentObserverRef.current) {
segmentObserverRef.current.disconnect();
}
};
}, [segmentRendered]);
if (!segmentRendered) {
return (
<div
key={segmentKey}
ref={segmentRef}
data-segment-id={segmentKey}
className={`segment ${segmentClasses}`}
/>
);
}
return (
<>
{(((firstHalfSegmentWidth > 0 || secondHalfSegmentWidth > 0) &&
@ -209,8 +166,7 @@ export function MotionSegment({
!motionOnly) && (
<div
key={segmentKey}
data-segment-id={segmentKey}
ref={segmentRef}
data-segment-id={segmentTime}
className={cn(
"segment",
{

View File

@ -0,0 +1,201 @@
import React, {
useCallback,
useEffect,
useRef,
useState,
forwardRef,
useImperativeHandle,
} from "react";
import MotionSegment from "./MotionSegment";
import { ReviewSegment, MotionData } from "@/types/review";
interface VirtualizedMotionSegmentsProps {
timelineRef: React.RefObject<HTMLDivElement>;
segments: number[];
events: ReviewSegment[];
motion_events: MotionData[];
segmentDuration: number;
timestampSpread: number;
showMinimap: boolean;
minimapStartTime?: number;
minimapEndTime?: number;
contentRef: React.RefObject<HTMLDivElement>;
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
dense: boolean;
motionOnly: boolean;
getMotionSegmentValue: (timestamp: number) => number;
}
export interface VirtualizedMotionSegmentsRef {
scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void;
}
const SEGMENT_HEIGHT = 8;
const OVERSCAN_COUNT = 20;
export const VirtualizedMotionSegments = forwardRef<
VirtualizedMotionSegmentsRef,
VirtualizedMotionSegmentsProps
>(
(
{
timelineRef,
segments,
events,
segmentDuration,
timestampSpread,
showMinimap,
minimapStartTime,
minimapEndTime,
setHandlebarTime,
dense,
motionOnly,
getMotionSegmentValue,
},
ref,
) => {
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 0 });
const containerRef = useRef<HTMLDivElement>(null);
const updateVisibleRange = useCallback(() => {
if (timelineRef.current) {
const { scrollTop, clientHeight } = timelineRef.current;
const start = Math.max(
0,
Math.floor(scrollTop / SEGMENT_HEIGHT) - OVERSCAN_COUNT,
);
const end = Math.min(
segments.length,
Math.ceil((scrollTop + clientHeight) / SEGMENT_HEIGHT) +
OVERSCAN_COUNT,
);
setVisibleRange({ start, end });
}
}, [segments.length, timelineRef]);
useEffect(() => {
const container = timelineRef.current;
if (container) {
const handleScroll = () => {
window.requestAnimationFrame(updateVisibleRange);
};
container.addEventListener("scroll", handleScroll, { passive: true });
window.addEventListener("resize", updateVisibleRange);
updateVisibleRange();
return () => {
container.removeEventListener("scroll", handleScroll);
window.removeEventListener("resize", updateVisibleRange);
};
}
}, [updateVisibleRange, timelineRef]);
const scrollToSegment = useCallback(
(segmentTime: number, ifNeeded: boolean = true) => {
const segmentIndex = segments.findIndex((time) => time === segmentTime);
if (
segmentIndex !== -1 &&
containerRef.current &&
timelineRef.current
) {
const timelineHeight = timelineRef.current.clientHeight;
const targetScrollTop = segmentIndex * SEGMENT_HEIGHT;
const centeredScrollTop =
targetScrollTop - timelineHeight / 2 + SEGMENT_HEIGHT / 2;
const isVisible =
segmentIndex > visibleRange.start + OVERSCAN_COUNT &&
segmentIndex < visibleRange.end - OVERSCAN_COUNT;
if (!ifNeeded || !isVisible) {
timelineRef.current.scrollTo({
top: Math.max(0, centeredScrollTop),
behavior: "smooth",
});
}
updateVisibleRange();
}
},
[segments, timelineRef, visibleRange, updateVisibleRange],
);
useImperativeHandle(ref, () => ({
scrollToSegment,
}));
const totalHeight = segments.length * SEGMENT_HEIGHT;
const visibleSegments = segments.slice(
visibleRange.start,
visibleRange.end,
);
return (
<div
ref={containerRef}
className="h-full w-full"
style={{ position: "relative", willChange: "transform" }}
>
<div style={{ height: `${totalHeight}px`, position: "relative" }}>
{visibleRange.start > 0 && (
<div
style={{
position: "absolute",
top: 0,
height: `${visibleRange.start * SEGMENT_HEIGHT}px`,
width: "100%",
}}
aria-hidden="true"
/>
)}
{visibleSegments.map((segmentTime, index) => {
const firstHalfMotionValue = getMotionSegmentValue(segmentTime);
const secondHalfMotionValue = getMotionSegmentValue(
segmentTime + segmentDuration / 2,
);
return (
<div
key={`${segmentTime}_${segmentDuration}`}
style={{
position: "absolute",
top: `${(visibleRange.start + index) * SEGMENT_HEIGHT}px`,
height: `${SEGMENT_HEIGHT}px`,
width: "100%",
}}
>
<MotionSegment
events={events}
firstHalfMotionValue={firstHalfMotionValue}
secondHalfMotionValue={secondHalfMotionValue}
segmentDuration={segmentDuration}
segmentTime={segmentTime}
timestampSpread={timestampSpread}
motionOnly={motionOnly}
showMinimap={showMinimap}
minimapStartTime={minimapStartTime}
minimapEndTime={minimapEndTime}
setHandlebarTime={setHandlebarTime}
scrollToSegment={scrollToSegment}
dense={dense}
/>
</div>
);
})}
{visibleRange.end < segments.length && (
<div
style={{
position: "absolute",
top: `${visibleRange.end * SEGMENT_HEIGHT}px`,
height: `${(segments.length - visibleRange.end) * SEGMENT_HEIGHT}px`,
width: "100%",
}}
aria-hidden="true"
/>
)}
</div>
</div>
);
},
);