From edfe44c1e39bd43d2d6912427292654f11424205 Mon Sep 17 00:00:00 2001 From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com> Date: Fri, 29 Nov 2024 20:16:47 -0600 Subject: [PATCH] virtualize event segments --- web/src/components/timeline/EventSegment.tsx | 54 +---- .../timeline/VirtualizedEventSegments.tsx | 205 ++++++++++++++++++ 2 files changed, 213 insertions(+), 46 deletions(-) create mode 100644 web/src/components/timeline/VirtualizedEventSegments.tsx diff --git a/web/src/components/timeline/EventSegment.tsx b/web/src/components/timeline/EventSegment.tsx index 242ca3248..b6bd9d137 100644 --- a/web/src/components/timeline/EventSegment.tsx +++ b/web/src/components/timeline/EventSegment.tsx @@ -8,7 +8,6 @@ import React, { useEffect, useMemo, useRef, - useState, } from "react"; import { HoverCard, @@ -31,6 +30,7 @@ type EventSegmentProps = { severityType: ReviewSeverity; contentRef: RefObject; setHandlebarTime?: React.Dispatch>; + scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void; dense: boolean; }; @@ -45,6 +45,7 @@ export function EventSegment({ severityType, contentRef, setHandlebarTime, + scrollToSegment, dense, }: EventSegmentProps) { const { @@ -95,7 +96,10 @@ export function EventSegment({ }, [getEventThumbnail, segmentTime]); const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]); - const segmentKey = useMemo(() => segmentTime, [segmentTime]); + const segmentKey = useMemo( + () => `${segmentTime}_${segmentDuration}`, + [segmentTime, segmentDuration], + ); const alignedMinimapStartTime = useMemo( () => alignStartDateToTimeline(minimapStartTime ?? 0), @@ -133,10 +137,7 @@ export function EventSegment({ // 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 @@ -196,49 +197,10 @@ export function EventSegment({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [startTimestamp]); - const [segmentRendered, setSegmentRendered] = useState(false); - const segmentObserverRef = useRef(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 ( -
- ); - } - return (
handleTouchStart(event, segmentClick)} diff --git a/web/src/components/timeline/VirtualizedEventSegments.tsx b/web/src/components/timeline/VirtualizedEventSegments.tsx new file mode 100644 index 000000000..cb9a97f56 --- /dev/null +++ b/web/src/components/timeline/VirtualizedEventSegments.tsx @@ -0,0 +1,205 @@ +import React, { + useCallback, + useEffect, + useRef, + useState, + forwardRef, + useImperativeHandle, +} from "react"; +import { EventSegment } from "./EventSegment"; +import { ReviewSegment, ReviewSeverity } from "@/types/review"; + +interface VirtualizedEventSegmentsProps { + timelineRef: React.RefObject; + segments: number[]; + events: ReviewSegment[]; + segmentDuration: number; + timestampSpread: number; + showMinimap: boolean; + minimapStartTime?: number; + minimapEndTime?: number; + severityType: ReviewSeverity; + contentRef: React.RefObject; + setHandlebarTime?: React.Dispatch>; + dense: boolean; + alignStartDateToTimeline: (timestamp: number) => number; +} + +export interface VirtualizedEventSegmentsRef { + scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void; +} + +const SEGMENT_HEIGHT = 8; +const OVERSCAN_COUNT = 20; + +export const VirtualizedEventSegments = forwardRef< + VirtualizedEventSegmentsRef, + VirtualizedEventSegmentsProps +>( + ( + { + timelineRef, + segments, + events, + segmentDuration, + timestampSpread, + showMinimap, + minimapStartTime, + minimapEndTime, + severityType, + contentRef, + setHandlebarTime, + dense, + alignStartDateToTimeline, + }, + ref, + ) => { + const [visibleRange, setVisibleRange] = useState({ start: 0, end: 0 }); + const containerRef = useRef(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 alignedSegmentTime = alignStartDateToTimeline(segmentTime); + const segmentIndex = segments.findIndex( + (time) => time === alignedSegmentTime, + ); + 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, + alignStartDateToTimeline, + updateVisibleRange, + timelineRef, + visibleRange, + ], + ); + + useImperativeHandle(ref, () => ({ + scrollToSegment, + })); + + const totalHeight = segments.length * SEGMENT_HEIGHT; + const visibleSegments = segments.slice( + visibleRange.start, + visibleRange.end, + ); + + return ( +
+
+ {visibleRange.start > 0 && ( + + ); + }, +);