virtualize event segments

This commit is contained in:
Josh Hawkins 2024-11-29 20:16:47 -06:00
parent ff92b13f35
commit edfe44c1e3
2 changed files with 213 additions and 46 deletions

View File

@ -8,7 +8,6 @@ import React, {
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
useState,
} from "react"; } from "react";
import { import {
HoverCard, HoverCard,
@ -31,6 +30,7 @@ type EventSegmentProps = {
severityType: ReviewSeverity; severityType: ReviewSeverity;
contentRef: RefObject<HTMLDivElement>; contentRef: RefObject<HTMLDivElement>;
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>; setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void;
dense: boolean; dense: boolean;
}; };
@ -45,6 +45,7 @@ export function EventSegment({
severityType, severityType,
contentRef, contentRef,
setHandlebarTime, setHandlebarTime,
scrollToSegment,
dense, dense,
}: EventSegmentProps) { }: EventSegmentProps) {
const { const {
@ -95,7 +96,10 @@ export function EventSegment({
}, [getEventThumbnail, segmentTime]); }, [getEventThumbnail, segmentTime]);
const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]); const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]);
const segmentKey = useMemo(() => segmentTime, [segmentTime]); const segmentKey = useMemo(
() => `${segmentTime}_${segmentDuration}`,
[segmentTime, segmentDuration],
);
const alignedMinimapStartTime = useMemo( const alignedMinimapStartTime = useMemo(
() => alignStartDateToTimeline(minimapStartTime ?? 0), () => alignStartDateToTimeline(minimapStartTime ?? 0),
@ -133,10 +137,7 @@ export function EventSegment({
// Check if the first segment is out of view // Check if the first segment is out of view
const firstSegment = firstMinimapSegmentRef.current; const firstSegment = firstMinimapSegmentRef.current;
if (firstSegment && showMinimap && isFirstSegmentInMinimap) { if (firstSegment && showMinimap && isFirstSegmentInMinimap) {
scrollIntoView(firstSegment, { scrollToSegment(alignedMinimapStartTime);
scrollMode: "if-needed",
behavior: "smooth",
});
} }
// we know that these deps are correct // we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
@ -196,49 +197,10 @@ export function EventSegment({
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [startTimestamp]); }, [startTimestamp]);
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 ( return (
<div <div
key={segmentKey} key={segmentKey}
ref={segmentRef} data-segment-id={segmentTime}
data-segment-id={segmentKey}
className={`segment ${segmentClasses}`} className={`segment ${segmentClasses}`}
onClick={segmentClick} onClick={segmentClick}
onTouchEnd={(event) => handleTouchStart(event, segmentClick)} onTouchEnd={(event) => handleTouchStart(event, segmentClick)}

View File

@ -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<HTMLDivElement>;
segments: number[];
events: ReviewSegment[];
segmentDuration: number;
timestampSpread: number;
showMinimap: boolean;
minimapStartTime?: number;
minimapEndTime?: number;
severityType: ReviewSeverity;
contentRef: React.RefObject<HTMLDivElement>;
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
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<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 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 (
<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 segmentId = `${segmentTime}_${segmentDuration}`;
return (
<div
key={segmentId}
style={{
position: "absolute",
top: `${(visibleRange.start + index) * SEGMENT_HEIGHT}px`,
height: `${SEGMENT_HEIGHT}px`,
width: "100%",
}}
>
<EventSegment
events={events}
segmentTime={segmentTime}
segmentDuration={segmentDuration}
timestampSpread={timestampSpread}
showMinimap={showMinimap}
minimapStartTime={minimapStartTime}
minimapEndTime={minimapEndTime}
severityType={severityType}
contentRef={contentRef}
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>
);
},
);