mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-02-20 13:54:36 +01:00
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:
@@ -1,9 +1,21 @@
|
||||
import { useEffect, useCallback, useMemo, useRef, RefObject } from "react";
|
||||
import EventSegment from "./EventSegment";
|
||||
import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
RefObject,
|
||||
useCallback,
|
||||
} from "react";
|
||||
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
||||
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||
import {
|
||||
ReviewSegment,
|
||||
ReviewSeverity,
|
||||
TimelineZoomDirection,
|
||||
} from "@/types/review";
|
||||
import ReviewTimeline from "./ReviewTimeline";
|
||||
import scrollIntoView from "scroll-into-view-if-needed";
|
||||
import {
|
||||
VirtualizedEventSegments,
|
||||
VirtualizedEventSegmentsRef,
|
||||
} from "./VirtualizedEventSegments";
|
||||
|
||||
export type EventReviewTimelineProps = {
|
||||
segmentDuration: number;
|
||||
@@ -27,6 +39,8 @@ export type EventReviewTimelineProps = {
|
||||
timelineRef?: RefObject<HTMLDivElement>;
|
||||
contentRef: RefObject<HTMLDivElement>;
|
||||
onHandlebarDraggingChange?: (isDragging: boolean) => void;
|
||||
isZooming: boolean;
|
||||
zoomDirection: TimelineZoomDirection;
|
||||
dense?: boolean;
|
||||
};
|
||||
|
||||
@@ -52,10 +66,13 @@ export function EventReviewTimeline({
|
||||
timelineRef,
|
||||
contentRef,
|
||||
onHandlebarDraggingChange,
|
||||
isZooming,
|
||||
zoomDirection,
|
||||
dense = false,
|
||||
}: EventReviewTimelineProps) {
|
||||
const internalTimelineRef = useRef<HTMLDivElement>(null);
|
||||
const selectedTimelineRef = timelineRef || internalTimelineRef;
|
||||
const virtualizedSegmentsRef = useRef<VirtualizedEventSegmentsRef>(null);
|
||||
|
||||
const timelineDuration = useMemo(
|
||||
() => timelineStart - timelineEnd,
|
||||
@@ -73,79 +90,27 @@ export function EventReviewTimeline({
|
||||
[timelineStart, alignStartDateToTimeline],
|
||||
);
|
||||
|
||||
// Generate segments for the timeline
|
||||
const generateSegments = useCallback(() => {
|
||||
// Generate segment times for the timeline
|
||||
const segmentTimes = useMemo(() => {
|
||||
const segmentCount = Math.ceil(timelineDuration / segmentDuration);
|
||||
|
||||
return Array.from({ length: segmentCount }, (_, index) => {
|
||||
const segmentTime = timelineStartAligned - index * segmentDuration;
|
||||
|
||||
return (
|
||||
<EventSegment
|
||||
key={segmentTime + severityType}
|
||||
events={events}
|
||||
segmentDuration={segmentDuration}
|
||||
segmentTime={segmentTime}
|
||||
timestampSpread={timestampSpread}
|
||||
showMinimap={showMinimap}
|
||||
minimapStartTime={minimapStartTime}
|
||||
minimapEndTime={minimapEndTime}
|
||||
severityType={severityType}
|
||||
contentRef={contentRef}
|
||||
setHandlebarTime={setHandlebarTime}
|
||||
dense={dense}
|
||||
/>
|
||||
);
|
||||
});
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
segmentDuration,
|
||||
timestampSpread,
|
||||
timelineStart,
|
||||
timelineDuration,
|
||||
showMinimap,
|
||||
minimapStartTime,
|
||||
minimapEndTime,
|
||||
events,
|
||||
]);
|
||||
|
||||
const segments = useMemo(
|
||||
() => generateSegments(),
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
segmentDuration,
|
||||
timestampSpread,
|
||||
timelineStart,
|
||||
timelineDuration,
|
||||
showMinimap,
|
||||
minimapStartTime,
|
||||
minimapEndTime,
|
||||
events,
|
||||
],
|
||||
);
|
||||
return Array.from(
|
||||
{ length: segmentCount },
|
||||
(_, index) => timelineStartAligned - index * segmentDuration,
|
||||
);
|
||||
}, [timelineDuration, segmentDuration, timelineStartAligned]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
selectedTimelineRef.current &&
|
||||
segments &&
|
||||
visibleTimestamps &&
|
||||
visibleTimestamps?.length > 0 &&
|
||||
!showMinimap
|
||||
visibleTimestamps.length > 0 &&
|
||||
!showMinimap &&
|
||||
virtualizedSegmentsRef.current
|
||||
) {
|
||||
const alignedVisibleTimestamps = visibleTimestamps.map(
|
||||
alignStartDateToTimeline,
|
||||
);
|
||||
const element = selectedTimelineRef.current?.querySelector(
|
||||
`[data-segment-id="${Math.max(...alignedVisibleTimestamps)}"]`,
|
||||
);
|
||||
if (element) {
|
||||
scrollIntoView(element, {
|
||||
scrollMode: "if-needed",
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
|
||||
scrollToSegment(Math.max(...alignedVisibleTimestamps), true);
|
||||
}
|
||||
// don't scroll when segments update from unreviewed -> reviewed
|
||||
// we know that these deps are correct
|
||||
@@ -155,8 +120,22 @@ export function EventReviewTimeline({
|
||||
showMinimap,
|
||||
alignStartDateToTimeline,
|
||||
visibleTimestamps,
|
||||
segmentDuration,
|
||||
]);
|
||||
|
||||
const scrollToSegment = useCallback(
|
||||
(segmentTime: number, ifNeeded?: boolean, behavior?: ScrollBehavior) => {
|
||||
if (virtualizedSegmentsRef.current) {
|
||||
virtualizedSegmentsRef.current.scrollToSegment(
|
||||
segmentTime,
|
||||
ifNeeded,
|
||||
behavior,
|
||||
);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<ReviewTimeline
|
||||
timelineRef={selectedTimelineRef}
|
||||
@@ -174,8 +153,27 @@ export function EventReviewTimeline({
|
||||
setExportStartTime={setExportStartTime}
|
||||
setExportEndTime={setExportEndTime}
|
||||
dense={dense}
|
||||
segments={segmentTimes}
|
||||
scrollToSegment={scrollToSegment}
|
||||
isZooming={isZooming}
|
||||
zoomDirection={zoomDirection}
|
||||
>
|
||||
{segments}
|
||||
<VirtualizedEventSegments
|
||||
ref={virtualizedSegmentsRef}
|
||||
timelineRef={selectedTimelineRef}
|
||||
segments={segmentTimes}
|
||||
events={events}
|
||||
segmentDuration={segmentDuration}
|
||||
timestampSpread={timestampSpread}
|
||||
showMinimap={showMinimap}
|
||||
minimapStartTime={minimapStartTime}
|
||||
minimapEndTime={minimapEndTime}
|
||||
severityType={severityType}
|
||||
contentRef={contentRef}
|
||||
setHandlebarTime={setHandlebarTime}
|
||||
dense={dense}
|
||||
alignStartDateToTimeline={alignStartDateToTimeline}
|
||||
/>
|
||||
</ReviewTimeline>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,6 @@ import React, {
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
HoverCard,
|
||||
@@ -31,6 +30,7 @@ type EventSegmentProps = {
|
||||
severityType: ReviewSeverity;
|
||||
contentRef: RefObject<HTMLDivElement>;
|
||||
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
|
||||
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<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 (
|
||||
<div
|
||||
key={segmentKey}
|
||||
ref={segmentRef}
|
||||
data-segment-id={segmentKey}
|
||||
data-segment-id={segmentTime}
|
||||
className={`segment ${segmentClasses}`}
|
||||
onClick={segmentClick}
|
||||
onTouchEnd={(event) => handleTouchStart(event, segmentClick)}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
{
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import useDraggableElement from "@/hooks/use-draggable-element";
|
||||
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { DraggableElement } from "@/types/draggable-element";
|
||||
import { TimelineZoomDirection } from "@/types/review";
|
||||
import {
|
||||
ReactNode,
|
||||
RefObject,
|
||||
@@ -30,7 +32,11 @@ export type ReviewTimelineProps = {
|
||||
setExportEndTime?: React.Dispatch<React.SetStateAction<number>>;
|
||||
timelineCollapsed?: boolean;
|
||||
dense: boolean;
|
||||
children: ReactNode[];
|
||||
segments: number[];
|
||||
scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void;
|
||||
isZooming: boolean;
|
||||
zoomDirection: TimelineZoomDirection;
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
export function ReviewTimeline({
|
||||
@@ -51,6 +57,10 @@ export function ReviewTimeline({
|
||||
setExportEndTime,
|
||||
timelineCollapsed = false,
|
||||
dense,
|
||||
segments,
|
||||
scrollToSegment,
|
||||
isZooming,
|
||||
zoomDirection,
|
||||
children,
|
||||
}: ReviewTimelineProps) {
|
||||
const [isDraggingHandlebar, setIsDraggingHandlebar] = useState(false);
|
||||
@@ -116,7 +126,8 @@ export function ReviewTimeline({
|
||||
setIsDragging: setIsDraggingHandlebar,
|
||||
draggableElementTimeRef: handlebarTimeRef,
|
||||
dense,
|
||||
timelineSegments: children,
|
||||
segments,
|
||||
scrollToSegment,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -140,7 +151,8 @@ export function ReviewTimeline({
|
||||
draggableElementTimeRef: exportStartTimeRef,
|
||||
setDraggableElementPosition: setExportStartPosition,
|
||||
dense,
|
||||
timelineSegments: children,
|
||||
segments,
|
||||
scrollToSegment,
|
||||
});
|
||||
|
||||
const {
|
||||
@@ -164,7 +176,8 @@ export function ReviewTimeline({
|
||||
draggableElementTimeRef: exportEndTimeRef,
|
||||
setDraggableElementPosition: setExportEndPosition,
|
||||
dense,
|
||||
timelineSegments: children,
|
||||
segments,
|
||||
scrollToSegment,
|
||||
});
|
||||
|
||||
const handleHandlebar = useCallback(
|
||||
@@ -316,18 +329,21 @@ export function ReviewTimeline({
|
||||
return (
|
||||
<div
|
||||
ref={timelineRef}
|
||||
className={`no-scrollbar relative h-full select-none overflow-y-auto bg-secondary ${
|
||||
className={cn(
|
||||
"no-scrollbar relative h-full select-none overflow-y-auto bg-secondary transition-all duration-500 ease-in-out",
|
||||
isZooming && zoomDirection === "in" && "animate-timeline-zoom-in",
|
||||
isZooming && zoomDirection === "out" && "animate-timeline-zoom-out",
|
||||
isDragging && (showHandlebar || showExportHandles)
|
||||
? "cursor-grabbing"
|
||||
: "cursor-auto"
|
||||
}`}
|
||||
: "cursor-auto",
|
||||
)}
|
||||
>
|
||||
<div ref={segmentsRef} className="relative flex flex-col">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 h-[30px] w-full bg-gradient-to-b from-secondary to-transparent"></div>
|
||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 h-[30px] w-full bg-gradient-to-t from-secondary to-transparent"></div>
|
||||
{children}
|
||||
</div>
|
||||
{children.length > 0 && (
|
||||
{children && (
|
||||
<>
|
||||
{showHandlebar && (
|
||||
<div
|
||||
|
||||
@@ -1,68 +1,39 @@
|
||||
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils";
|
||||
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||
import React, { useMemo } from "react";
|
||||
// import useTapUtils from "@/hooks/use-tap-utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ConsolidatedSegmentData } from "@/types/review";
|
||||
|
||||
type SummarySegmentProps = {
|
||||
events: ReviewSegment[];
|
||||
segmentTime: number;
|
||||
segmentDuration: number;
|
||||
segmentHeight: number;
|
||||
severityType: ReviewSeverity;
|
||||
segmentData: ConsolidatedSegmentData;
|
||||
totalDuration: number;
|
||||
};
|
||||
|
||||
export function SummarySegment({
|
||||
events,
|
||||
segmentTime,
|
||||
segmentDuration,
|
||||
segmentHeight,
|
||||
severityType,
|
||||
segmentData,
|
||||
totalDuration,
|
||||
}: SummarySegmentProps) {
|
||||
const { getSeverity, getReviewed, displaySeverityType } =
|
||||
useEventSegmentUtils(segmentDuration, events, severityType);
|
||||
const { startTime, endTime, severity, reviewed } = segmentData;
|
||||
|
||||
const severity = useMemo(
|
||||
() => getSeverity(segmentTime, displaySeverityType),
|
||||
[getSeverity, segmentTime, displaySeverityType],
|
||||
);
|
||||
|
||||
const reviewed = useMemo(
|
||||
() => getReviewed(segmentTime),
|
||||
[getReviewed, segmentTime],
|
||||
);
|
||||
|
||||
const segmentKey = useMemo(() => segmentTime, [segmentTime]);
|
||||
|
||||
const severityColors: { [key: number]: string } = {
|
||||
1: reviewed
|
||||
const severityColors: { [key: string]: string } = {
|
||||
significant_motion: reviewed
|
||||
? "bg-severity_significant_motion/50"
|
||||
: "bg-severity_significant_motion",
|
||||
2: reviewed ? "bg-severity_detection/50" : "bg-severity_detection",
|
||||
3: reviewed ? "bg-severity_alert/50" : "bg-severity_alert",
|
||||
detection: reviewed ? "bg-severity_detection/50" : "bg-severity_detection",
|
||||
alert: reviewed ? "bg-severity_alert/50" : "bg-severity_alert",
|
||||
empty: "bg-transparent",
|
||||
};
|
||||
|
||||
const height = ((endTime - startTime) / totalDuration) * 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={segmentKey}
|
||||
className="relative w-full"
|
||||
style={{ height: segmentHeight }}
|
||||
>
|
||||
{severity.map((severityValue: number, index: number) => (
|
||||
<React.Fragment key={index}>
|
||||
{severityValue === displaySeverityType && (
|
||||
<div
|
||||
className="flex cursor-pointer justify-end"
|
||||
style={{ height: segmentHeight }}
|
||||
>
|
||||
<div
|
||||
key={`${segmentKey}_${index}_secondary_data`}
|
||||
style={{ height: segmentHeight }}
|
||||
className={`w-[10px] ${severityColors[severityValue]}`}
|
||||
></div>
|
||||
</div>
|
||||
<div className="relative w-full" style={{ height: `${height}%` }}>
|
||||
<div className="absolute inset-0 flex h-full cursor-pointer justify-end">
|
||||
<div
|
||||
className={cn(
|
||||
"w-[10px]",
|
||||
severityColors[severity],
|
||||
height < 0.5 && "min-h-[0.5px]",
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,17 +1,20 @@
|
||||
import {
|
||||
RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
import React, {
|
||||
useRef,
|
||||
useState,
|
||||
useMemo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
} from "react";
|
||||
import { SummarySegment } from "./SummarySegment";
|
||||
import {
|
||||
ConsolidatedSegmentData,
|
||||
ReviewSegment,
|
||||
ReviewSeverity,
|
||||
} from "@/types/review";
|
||||
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
||||
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||
|
||||
export type SummaryTimelineProps = {
|
||||
reviewTimelineRef: RefObject<HTMLDivElement>;
|
||||
reviewTimelineRef: React.RefObject<HTMLDivElement>;
|
||||
timelineStart: number;
|
||||
timelineEnd: number;
|
||||
segmentDuration: number;
|
||||
@@ -29,7 +32,6 @@ export function SummaryTimeline({
|
||||
}: SummaryTimelineProps) {
|
||||
const summaryTimelineRef = useRef<HTMLDivElement>(null);
|
||||
const visibleSectionRef = useRef<HTMLDivElement>(null);
|
||||
const [segmentHeight, setSegmentHeight] = useState(0);
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [scrollStartPosition, setScrollStartPosition] = useState<number>(0);
|
||||
@@ -43,61 +45,89 @@ export function SummaryTimeline({
|
||||
[timelineEnd, timelineStart, segmentDuration],
|
||||
);
|
||||
|
||||
const { alignStartDateToTimeline } = useTimelineUtils({
|
||||
segmentDuration,
|
||||
timelineDuration: reviewTimelineDuration,
|
||||
timelineRef: reviewTimelineRef,
|
||||
});
|
||||
|
||||
const timelineStartAligned = useMemo(
|
||||
() => alignStartDateToTimeline(timelineStart) + 2 * segmentDuration,
|
||||
[timelineStart, alignStartDateToTimeline, segmentDuration],
|
||||
const { alignStartDateToTimeline, alignEndDateToTimeline } = useTimelineUtils(
|
||||
{
|
||||
segmentDuration,
|
||||
timelineDuration: reviewTimelineDuration,
|
||||
timelineRef: reviewTimelineRef,
|
||||
},
|
||||
);
|
||||
|
||||
// Generate segments for the timeline
|
||||
const generateSegments = useCallback(() => {
|
||||
const segmentCount = Math.ceil(reviewTimelineDuration / segmentDuration);
|
||||
const consolidatedSegments = useMemo(() => {
|
||||
const filteredEvents = events.filter(
|
||||
(event) => event.severity === severityType,
|
||||
);
|
||||
|
||||
if (segmentHeight) {
|
||||
return Array.from({ length: segmentCount }, (_, index) => {
|
||||
const segmentTime = timelineStartAligned - index * segmentDuration;
|
||||
const sortedEvents = filteredEvents.sort(
|
||||
(a, b) => a.start_time - b.start_time,
|
||||
);
|
||||
|
||||
return (
|
||||
<SummarySegment
|
||||
key={segmentTime}
|
||||
events={events}
|
||||
segmentDuration={segmentDuration}
|
||||
segmentTime={segmentTime}
|
||||
segmentHeight={segmentHeight}
|
||||
severityType={severityType}
|
||||
/>
|
||||
);
|
||||
const consolidated: ConsolidatedSegmentData[] = [];
|
||||
|
||||
let currentTime = alignEndDateToTimeline(timelineEnd);
|
||||
const timelineStartAligned = alignStartDateToTimeline(timelineStart);
|
||||
|
||||
sortedEvents.forEach((event) => {
|
||||
const alignedStartTime = Math.max(
|
||||
alignStartDateToTimeline(event.start_time),
|
||||
currentTime,
|
||||
);
|
||||
const alignedEndTime = Math.min(
|
||||
event.end_time
|
||||
? alignEndDateToTimeline(event.end_time)
|
||||
: alignedStartTime + segmentDuration,
|
||||
timelineStartAligned,
|
||||
);
|
||||
|
||||
if (alignedStartTime < alignedEndTime) {
|
||||
if (alignedStartTime > currentTime) {
|
||||
consolidated.push({
|
||||
startTime: currentTime,
|
||||
endTime: alignedStartTime,
|
||||
severity: "empty",
|
||||
reviewed: false,
|
||||
});
|
||||
}
|
||||
|
||||
consolidated.push({
|
||||
startTime: alignedStartTime,
|
||||
endTime: alignedEndTime,
|
||||
severity: event.severity,
|
||||
reviewed: event.has_been_reviewed,
|
||||
});
|
||||
|
||||
currentTime = alignedEndTime;
|
||||
}
|
||||
});
|
||||
|
||||
if (currentTime < timelineStartAligned) {
|
||||
consolidated.push({
|
||||
startTime: currentTime,
|
||||
endTime: timelineStartAligned,
|
||||
severity: "empty",
|
||||
reviewed: false,
|
||||
});
|
||||
}
|
||||
}, [
|
||||
segmentDuration,
|
||||
timelineStartAligned,
|
||||
events,
|
||||
reviewTimelineDuration,
|
||||
segmentHeight,
|
||||
severityType,
|
||||
]);
|
||||
|
||||
const segments = useMemo(
|
||||
() => generateSegments(),
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
segmentDuration,
|
||||
segmentHeight,
|
||||
timelineStartAligned,
|
||||
events,
|
||||
reviewTimelineDuration,
|
||||
segmentHeight,
|
||||
generateSegments,
|
||||
severityType,
|
||||
],
|
||||
);
|
||||
return consolidated.length > 0
|
||||
? consolidated
|
||||
: [
|
||||
{
|
||||
startTime: alignEndDateToTimeline(timelineEnd),
|
||||
endTime: timelineStartAligned,
|
||||
severity: "empty" as const,
|
||||
reviewed: false,
|
||||
},
|
||||
];
|
||||
}, [
|
||||
events,
|
||||
severityType,
|
||||
timelineStart,
|
||||
timelineEnd,
|
||||
alignStartDateToTimeline,
|
||||
alignEndDateToTimeline,
|
||||
segmentDuration,
|
||||
]);
|
||||
|
||||
const setVisibleSectionStyles = useCallback(() => {
|
||||
if (
|
||||
@@ -137,15 +167,6 @@ export function SummaryTimeline({
|
||||
|
||||
observer.current = new ResizeObserver(() => {
|
||||
setVisibleSectionStyles();
|
||||
if (summaryTimelineRef.current) {
|
||||
const { clientHeight: summaryTimelineVisibleHeight } =
|
||||
summaryTimelineRef.current;
|
||||
|
||||
setSegmentHeight(
|
||||
summaryTimelineVisibleHeight /
|
||||
(reviewTimelineDuration / segmentDuration),
|
||||
);
|
||||
}
|
||||
});
|
||||
observer.current.observe(content);
|
||||
|
||||
@@ -163,18 +184,6 @@ export function SummaryTimeline({
|
||||
segmentDuration,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (summaryTimelineRef.current) {
|
||||
const { clientHeight: summaryTimelineVisibleHeight } =
|
||||
summaryTimelineRef.current;
|
||||
|
||||
setSegmentHeight(
|
||||
summaryTimelineVisibleHeight /
|
||||
(reviewTimelineDuration / segmentDuration),
|
||||
);
|
||||
}
|
||||
}, [reviewTimelineDuration, summaryTimelineRef, segmentDuration]);
|
||||
|
||||
const timelineClick = useCallback(
|
||||
(
|
||||
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,
|
||||
@@ -344,11 +353,17 @@ export function SummaryTimeline({
|
||||
>
|
||||
<div
|
||||
ref={summaryTimelineRef}
|
||||
className="relative z-10 flex h-full flex-col"
|
||||
className="relative z-10 flex h-full flex-col-reverse"
|
||||
onClick={timelineClick}
|
||||
onTouchEnd={timelineClick}
|
||||
>
|
||||
{segments}
|
||||
{consolidatedSegments.map((segment, index) => (
|
||||
<SummarySegment
|
||||
key={`${segment.startTime}-${segment.endTime}+${index}`}
|
||||
segmentData={segment}
|
||||
totalDuration={timelineStart - timelineEnd}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
ref={visibleSectionRef}
|
||||
|
||||
213
web/src/components/timeline/VirtualizedEventSegments.tsx
Normal file
213
web/src/components/timeline/VirtualizedEventSegments.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import { EventSegment } from "./EventSegment";
|
||||
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||
|
||||
type 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,
|
||||
behavior?: ScrollBehavior,
|
||||
) => 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,
|
||||
behavior: ScrollBehavior = "smooth",
|
||||
) => {
|
||||
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: behavior,
|
||||
});
|
||||
}
|
||||
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>
|
||||
);
|
||||
},
|
||||
);
|
||||
251
web/src/components/timeline/VirtualizedMotionSegments.tsx
Normal file
251
web/src/components/timeline/VirtualizedMotionSegments.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import React, {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
forwardRef,
|
||||
useImperativeHandle,
|
||||
} from "react";
|
||||
import MotionSegment from "./MotionSegment";
|
||||
import { ReviewSegment, MotionData } from "@/types/review";
|
||||
|
||||
type 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,
|
||||
behavior?: ScrollBehavior,
|
||||
) => 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,
|
||||
behavior: ScrollBehavior = "smooth",
|
||||
) => {
|
||||
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: behavior,
|
||||
});
|
||||
}
|
||||
updateVisibleRange();
|
||||
}
|
||||
},
|
||||
[segments, timelineRef, visibleRange, updateVisibleRange],
|
||||
);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToSegment,
|
||||
}));
|
||||
|
||||
const renderSegment = useCallback(
|
||||
(segmentTime: number, index: number) => {
|
||||
const motionStart = segmentTime;
|
||||
const motionEnd = motionStart + segmentDuration;
|
||||
|
||||
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 ?? segmentTime) > motionStart &&
|
||||
(item.end_time ?? segmentTime) <= motionEnd) ||
|
||||
(item.start_time <= motionStart &&
|
||||
(item.end_time ?? segmentTime) >= motionEnd),
|
||||
);
|
||||
|
||||
if ((!segmentMotion || overlappingReviewItems) && motionOnly) {
|
||||
return null; // Skip rendering this segment in motion only mode
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
},
|
||||
[
|
||||
events,
|
||||
getMotionSegmentValue,
|
||||
motionOnly,
|
||||
segmentDuration,
|
||||
showMinimap,
|
||||
minimapStartTime,
|
||||
minimapEndTime,
|
||||
setHandlebarTime,
|
||||
scrollToSegment,
|
||||
dense,
|
||||
timestampSpread,
|
||||
visibleRange.start,
|
||||
],
|
||||
);
|
||||
|
||||
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) =>
|
||||
renderSegment(segmentTime, index),
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default VirtualizedMotionSegments;
|
||||
@@ -22,7 +22,7 @@ type TimestampSegmentProps = {
|
||||
isLastSegmentInMinimap: boolean;
|
||||
timestamp: Date;
|
||||
timestampSpread: number;
|
||||
segmentKey: number;
|
||||
segmentKey: string;
|
||||
};
|
||||
|
||||
export function MinimapBounds({
|
||||
|
||||
Reference in New Issue
Block a user