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 15:13:32 -06:00 committed by GitHub
parent 1f89844c67
commit cc2dbdcb44
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 1157 additions and 515 deletions

View File

@ -1,9 +1,21 @@
import { useEffect, useCallback, useMemo, useRef, RefObject } from "react"; import React, {
import EventSegment from "./EventSegment"; useEffect,
useMemo,
useRef,
RefObject,
useCallback,
} from "react";
import { useTimelineUtils } from "@/hooks/use-timeline-utils"; 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 ReviewTimeline from "./ReviewTimeline";
import scrollIntoView from "scroll-into-view-if-needed"; import {
VirtualizedEventSegments,
VirtualizedEventSegmentsRef,
} from "./VirtualizedEventSegments";
export type EventReviewTimelineProps = { export type EventReviewTimelineProps = {
segmentDuration: number; segmentDuration: number;
@ -27,6 +39,8 @@ export type EventReviewTimelineProps = {
timelineRef?: RefObject<HTMLDivElement>; timelineRef?: RefObject<HTMLDivElement>;
contentRef: RefObject<HTMLDivElement>; contentRef: RefObject<HTMLDivElement>;
onHandlebarDraggingChange?: (isDragging: boolean) => void; onHandlebarDraggingChange?: (isDragging: boolean) => void;
isZooming: boolean;
zoomDirection: TimelineZoomDirection;
dense?: boolean; dense?: boolean;
}; };
@ -52,10 +66,13 @@ export function EventReviewTimeline({
timelineRef, timelineRef,
contentRef, contentRef,
onHandlebarDraggingChange, onHandlebarDraggingChange,
isZooming,
zoomDirection,
dense = false, dense = false,
}: EventReviewTimelineProps) { }: EventReviewTimelineProps) {
const internalTimelineRef = useRef<HTMLDivElement>(null); const internalTimelineRef = useRef<HTMLDivElement>(null);
const selectedTimelineRef = timelineRef || internalTimelineRef; const selectedTimelineRef = timelineRef || internalTimelineRef;
const virtualizedSegmentsRef = useRef<VirtualizedEventSegmentsRef>(null);
const timelineDuration = useMemo( const timelineDuration = useMemo(
() => timelineStart - timelineEnd, () => timelineStart - timelineEnd,
@ -73,79 +90,27 @@ export function EventReviewTimeline({
[timelineStart, alignStartDateToTimeline], [timelineStart, alignStartDateToTimeline],
); );
// Generate segments for the timeline // Generate segment times for the timeline
const generateSegments = useCallback(() => { const segmentTimes = useMemo(() => {
const segmentCount = Math.ceil(timelineDuration / segmentDuration); const segmentCount = Math.ceil(timelineDuration / segmentDuration);
return Array.from(
return Array.from({ length: segmentCount }, (_, index) => { { length: segmentCount },
const segmentTime = timelineStartAligned - index * segmentDuration; (_, index) => 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,
],
); );
}, [timelineDuration, segmentDuration, timelineStartAligned]);
useEffect(() => { useEffect(() => {
if ( if (
selectedTimelineRef.current &&
segments &&
visibleTimestamps && visibleTimestamps &&
visibleTimestamps?.length > 0 && visibleTimestamps.length > 0 &&
!showMinimap !showMinimap &&
virtualizedSegmentsRef.current
) { ) {
const alignedVisibleTimestamps = visibleTimestamps.map( const alignedVisibleTimestamps = visibleTimestamps.map(
alignStartDateToTimeline, alignStartDateToTimeline,
); );
const element = selectedTimelineRef.current?.querySelector(
`[data-segment-id="${Math.max(...alignedVisibleTimestamps)}"]`, scrollToSegment(Math.max(...alignedVisibleTimestamps), true);
);
if (element) {
scrollIntoView(element, {
scrollMode: "if-needed",
behavior: "smooth",
});
}
} }
// don't scroll when segments update from unreviewed -> reviewed // don't scroll when segments update from unreviewed -> reviewed
// we know that these deps are correct // we know that these deps are correct
@ -155,8 +120,22 @@ export function EventReviewTimeline({
showMinimap, showMinimap,
alignStartDateToTimeline, alignStartDateToTimeline,
visibleTimestamps, visibleTimestamps,
segmentDuration,
]); ]);
const scrollToSegment = useCallback(
(segmentTime: number, ifNeeded?: boolean, behavior?: ScrollBehavior) => {
if (virtualizedSegmentsRef.current) {
virtualizedSegmentsRef.current.scrollToSegment(
segmentTime,
ifNeeded,
behavior,
);
}
},
[],
);
return ( return (
<ReviewTimeline <ReviewTimeline
timelineRef={selectedTimelineRef} timelineRef={selectedTimelineRef}
@ -174,8 +153,27 @@ export function EventReviewTimeline({
setExportStartTime={setExportStartTime} setExportStartTime={setExportStartTime}
setExportEndTime={setExportEndTime} setExportEndTime={setExportEndTime}
dense={dense} 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> </ReviewTimeline>
); );
} }

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 ( return (
<div <div
key={segmentKey} key={segmentKey}
ref={segmentRef} data-segment-id={segmentTime}
data-segment-id={segmentKey}
className={`segment ${segmentClasses}`}
/>
);
}
return (
<div
key={segmentKey}
ref={segmentRef}
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

@ -1,9 +1,22 @@
import { useCallback, useMemo, useRef, RefObject } from "react"; import React, {
import MotionSegment from "./MotionSegment"; useCallback,
useMemo,
useRef,
RefObject,
useEffect,
} from "react";
import { useTimelineUtils } from "@/hooks/use-timeline-utils"; 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 ReviewTimeline from "./ReviewTimeline";
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils"; import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
import {
VirtualizedMotionSegments,
VirtualizedMotionSegmentsRef,
} from "./VirtualizedMotionSegments";
export type MotionReviewTimelineProps = { export type MotionReviewTimelineProps = {
segmentDuration: number; segmentDuration: number;
@ -25,11 +38,12 @@ export type MotionReviewTimelineProps = {
setExportEndTime?: React.Dispatch<React.SetStateAction<number>>; setExportEndTime?: React.Dispatch<React.SetStateAction<number>>;
events: ReviewSegment[]; events: ReviewSegment[];
motion_events: MotionData[]; motion_events: MotionData[];
severityType: ReviewSeverity;
contentRef: RefObject<HTMLDivElement>; contentRef: RefObject<HTMLDivElement>;
timelineRef?: RefObject<HTMLDivElement>; timelineRef?: RefObject<HTMLDivElement>;
onHandlebarDraggingChange?: (isDragging: boolean) => void; onHandlebarDraggingChange?: (isDragging: boolean) => void;
dense?: boolean; dense?: boolean;
isZooming: boolean;
zoomDirection: TimelineZoomDirection;
}; };
export function MotionReviewTimeline({ export function MotionReviewTimeline({
@ -56,9 +70,12 @@ export function MotionReviewTimeline({
timelineRef, timelineRef,
onHandlebarDraggingChange, onHandlebarDraggingChange,
dense = false, dense = false,
isZooming,
zoomDirection,
}: MotionReviewTimelineProps) { }: MotionReviewTimelineProps) {
const internalTimelineRef = useRef<HTMLDivElement>(null); const internalTimelineRef = useRef<HTMLDivElement>(null);
const selectedTimelineRef = timelineRef || internalTimelineRef; const selectedTimelineRef = timelineRef || internalTimelineRef;
const virtualizedSegmentsRef = useRef<VirtualizedMotionSegmentsRef>(null);
const timelineDuration = useMemo( const timelineDuration = useMemo(
() => timelineStart - timelineEnd + 4 * segmentDuration, () => timelineStart - timelineEnd + 4 * segmentDuration,
@ -80,22 +97,16 @@ export function MotionReviewTimeline({
motion_events, motion_events,
); );
// Generate segments for the timeline const segmentTimes = useMemo(() => {
const generateSegments = useCallback(() => {
const segments = []; const segments = [];
let segmentTime = timelineStartAligned; let segmentTime = timelineStartAligned;
while (segmentTime >= timelineStartAligned - timelineDuration) { for (let i = 0; i < Math.ceil(timelineDuration / segmentDuration); i++) {
if (!motionOnly) {
segments.push(segmentTime);
} else {
const motionStart = segmentTime; const motionStart = segmentTime;
const motionEnd = motionStart + segmentDuration; const motionEnd = motionStart + segmentDuration;
const firstHalfMotionValue = getMotionSegmentValue(motionStart);
const secondHalfMotionValue = getMotionSegmentValue(
motionStart + segmentDuration / 2,
);
const segmentMotion =
firstHalfMotionValue > 0 || secondHalfMotionValue > 0;
const overlappingReviewItems = events.some( const overlappingReviewItems = events.some(
(item) => (item) =>
(item.start_time >= motionStart && item.start_time < motionEnd) || (item.start_time >= motionStart && item.start_time < motionEnd) ||
@ -104,65 +115,56 @@ export function MotionReviewTimeline({
(item.start_time <= motionStart && (item.start_time <= motionStart &&
(item.end_time ?? timelineStart) >= motionEnd), (item.end_time ?? timelineStart) >= motionEnd),
); );
const firstHalfMotionValue = getMotionSegmentValue(motionStart);
if ((!segmentMotion || overlappingReviewItems) && motionOnly) { const secondHalfMotionValue = getMotionSegmentValue(
// exclude segment if necessary when in motion only mode motionStart + segmentDuration / 2,
segmentTime -= segmentDuration;
continue;
}
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}
/>,
); );
const segmentMotion =
firstHalfMotionValue > 0 || secondHalfMotionValue > 0;
if (segmentMotion && !overlappingReviewItems) {
segments.push(segmentTime);
}
}
segmentTime -= segmentDuration; segmentTime -= segmentDuration;
} }
return segments; return segments;
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ }, [
segmentDuration,
timestampSpread,
timelineStartAligned, timelineStartAligned,
segmentDuration,
timelineDuration, timelineDuration,
showMinimap,
minimapStartTime,
minimapEndTime,
events,
motion_events,
motionOnly, motionOnly,
getMotionSegmentValue,
events,
timelineStart,
]); ]);
const segments = useMemo( const scrollToSegment = useCallback(
() => generateSegments(), (segmentTime: number, ifNeeded?: boolean, behavior?: ScrollBehavior) => {
// we know that these deps are correct if (virtualizedSegmentsRef.current) {
// eslint-disable-next-line react-hooks/exhaustive-deps virtualizedSegmentsRef.current.scrollToSegment(
[ segmentTime,
segmentDuration, ifNeeded,
timestampSpread, behavior,
timelineStartAligned,
timelineDuration,
showMinimap,
minimapStartTime,
minimapEndTime,
events,
motion_events,
motionOnly,
],
); );
}
},
[],
);
// 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 ( return (
<ReviewTimeline <ReviewTimeline
@ -183,8 +185,28 @@ export function MotionReviewTimeline({
setExportEndTime={setExportEndTime} setExportEndTime={setExportEndTime}
timelineCollapsed={motionOnly} timelineCollapsed={motionOnly}
dense={dense} 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> </ReviewTimeline>
); );
} }

View File

@ -1,14 +1,7 @@
import { useTimelineUtils } from "@/hooks/use-timeline-utils"; import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils"; import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils";
import { ReviewSegment } from "@/types/review"; import { ReviewSegment } from "@/types/review";
import React, { import React, { useCallback, useEffect, useMemo, useRef } from "react";
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import { MinimapBounds, Tick, Timestamp } from "./segment-metadata"; import { MinimapBounds, Tick, Timestamp } from "./segment-metadata";
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils"; import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
@ -27,6 +20,7 @@ type MotionSegmentProps = {
minimapStartTime?: number; minimapStartTime?: number;
minimapEndTime?: number; minimapEndTime?: number;
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>; setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void;
dense: boolean; dense: boolean;
}; };
@ -42,6 +36,7 @@ export function MotionSegment({
minimapStartTime, minimapStartTime,
minimapEndTime, minimapEndTime,
setHandlebarTime, setHandlebarTime,
scrollToSegment,
dense, dense,
}: MotionSegmentProps) { }: MotionSegmentProps) {
const severityType = "all"; const severityType = "all";
@ -72,7 +67,10 @@ export function MotionSegment({
); );
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 maxSegmentWidth = useMemo(() => { const maxSegmentWidth = useMemo(() => {
return isMobile ? 30 : 50; return isMobile ? 30 : 50;
@ -122,10 +120,7 @@ export function MotionSegment({
// 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
@ -163,44 +158,6 @@ export function MotionSegment({
} }
}, [segmentTime, setHandlebarTime]); }, [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 ( return (
<> <>
{(((firstHalfSegmentWidth > 0 || secondHalfSegmentWidth > 0) && {(((firstHalfSegmentWidth > 0 || secondHalfSegmentWidth > 0) &&
@ -209,8 +166,7 @@ export function MotionSegment({
!motionOnly) && ( !motionOnly) && (
<div <div
key={segmentKey} key={segmentKey}
data-segment-id={segmentKey} data-segment-id={segmentTime}
ref={segmentRef}
className={cn( className={cn(
"segment", "segment",
{ {

View File

@ -1,6 +1,8 @@
import useDraggableElement from "@/hooks/use-draggable-element"; import useDraggableElement from "@/hooks/use-draggable-element";
import { useTimelineUtils } from "@/hooks/use-timeline-utils"; import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { cn } from "@/lib/utils";
import { DraggableElement } from "@/types/draggable-element"; import { DraggableElement } from "@/types/draggable-element";
import { TimelineZoomDirection } from "@/types/review";
import { import {
ReactNode, ReactNode,
RefObject, RefObject,
@ -30,7 +32,11 @@ export type ReviewTimelineProps = {
setExportEndTime?: React.Dispatch<React.SetStateAction<number>>; setExportEndTime?: React.Dispatch<React.SetStateAction<number>>;
timelineCollapsed?: boolean; timelineCollapsed?: boolean;
dense: boolean; dense: boolean;
children: ReactNode[]; segments: number[];
scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void;
isZooming: boolean;
zoomDirection: TimelineZoomDirection;
children: ReactNode;
}; };
export function ReviewTimeline({ export function ReviewTimeline({
@ -51,6 +57,10 @@ export function ReviewTimeline({
setExportEndTime, setExportEndTime,
timelineCollapsed = false, timelineCollapsed = false,
dense, dense,
segments,
scrollToSegment,
isZooming,
zoomDirection,
children, children,
}: ReviewTimelineProps) { }: ReviewTimelineProps) {
const [isDraggingHandlebar, setIsDraggingHandlebar] = useState(false); const [isDraggingHandlebar, setIsDraggingHandlebar] = useState(false);
@ -116,7 +126,8 @@ export function ReviewTimeline({
setIsDragging: setIsDraggingHandlebar, setIsDragging: setIsDraggingHandlebar,
draggableElementTimeRef: handlebarTimeRef, draggableElementTimeRef: handlebarTimeRef,
dense, dense,
timelineSegments: children, segments,
scrollToSegment,
}); });
const { const {
@ -140,7 +151,8 @@ export function ReviewTimeline({
draggableElementTimeRef: exportStartTimeRef, draggableElementTimeRef: exportStartTimeRef,
setDraggableElementPosition: setExportStartPosition, setDraggableElementPosition: setExportStartPosition,
dense, dense,
timelineSegments: children, segments,
scrollToSegment,
}); });
const { const {
@ -164,7 +176,8 @@ export function ReviewTimeline({
draggableElementTimeRef: exportEndTimeRef, draggableElementTimeRef: exportEndTimeRef,
setDraggableElementPosition: setExportEndPosition, setDraggableElementPosition: setExportEndPosition,
dense, dense,
timelineSegments: children, segments,
scrollToSegment,
}); });
const handleHandlebar = useCallback( const handleHandlebar = useCallback(
@ -316,18 +329,21 @@ export function ReviewTimeline({
return ( return (
<div <div
ref={timelineRef} 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) isDragging && (showHandlebar || showExportHandles)
? "cursor-grabbing" ? "cursor-grabbing"
: "cursor-auto" : "cursor-auto",
}`} )}
> >
<div ref={segmentsRef} className="relative flex flex-col"> <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 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> <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} {children}
</div> </div>
{children.length > 0 && ( {children && (
<> <>
{showHandlebar && ( {showHandlebar && (
<div <div

View File

@ -1,68 +1,39 @@
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils"; import { cn } from "@/lib/utils";
import { ReviewSegment, ReviewSeverity } from "@/types/review"; import { ConsolidatedSegmentData } from "@/types/review";
import React, { useMemo } from "react";
// import useTapUtils from "@/hooks/use-tap-utils";
type SummarySegmentProps = { type SummarySegmentProps = {
events: ReviewSegment[]; segmentData: ConsolidatedSegmentData;
segmentTime: number; totalDuration: number;
segmentDuration: number;
segmentHeight: number;
severityType: ReviewSeverity;
}; };
export function SummarySegment({ export function SummarySegment({
events, segmentData,
segmentTime, totalDuration,
segmentDuration,
segmentHeight,
severityType,
}: SummarySegmentProps) { }: SummarySegmentProps) {
const { getSeverity, getReviewed, displaySeverityType } = const { startTime, endTime, severity, reviewed } = segmentData;
useEventSegmentUtils(segmentDuration, events, severityType);
const severity = useMemo( const severityColors: { [key: string]: string } = {
() => getSeverity(segmentTime, displaySeverityType), significant_motion: reviewed
[getSeverity, segmentTime, displaySeverityType],
);
const reviewed = useMemo(
() => getReviewed(segmentTime),
[getReviewed, segmentTime],
);
const segmentKey = useMemo(() => segmentTime, [segmentTime]);
const severityColors: { [key: number]: string } = {
1: reviewed
? "bg-severity_significant_motion/50" ? "bg-severity_significant_motion/50"
: "bg-severity_significant_motion", : "bg-severity_significant_motion",
2: reviewed ? "bg-severity_detection/50" : "bg-severity_detection", detection: reviewed ? "bg-severity_detection/50" : "bg-severity_detection",
3: reviewed ? "bg-severity_alert/50" : "bg-severity_alert", alert: reviewed ? "bg-severity_alert/50" : "bg-severity_alert",
empty: "bg-transparent",
}; };
const height = ((endTime - startTime) / totalDuration) * 100;
return ( return (
<div className="relative w-full" style={{ height: `${height}%` }}>
<div className="absolute inset-0 flex h-full cursor-pointer justify-end">
<div <div
key={segmentKey} className={cn(
className="relative w-full" "w-[10px]",
style={{ height: segmentHeight }} severityColors[severity],
> height < 0.5 && "min-h-[0.5px]",
{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> </div>
)}
</React.Fragment>
))}
</div> </div>
); );
} }

View File

@ -1,17 +1,20 @@
import { import React, {
RefObject,
useCallback,
useEffect,
useMemo,
useRef, useRef,
useState, useState,
useMemo,
useCallback,
useEffect,
} from "react"; } from "react";
import { SummarySegment } from "./SummarySegment"; import { SummarySegment } from "./SummarySegment";
import {
ConsolidatedSegmentData,
ReviewSegment,
ReviewSeverity,
} from "@/types/review";
import { useTimelineUtils } from "@/hooks/use-timeline-utils"; import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { ReviewSegment, ReviewSeverity } from "@/types/review";
export type SummaryTimelineProps = { export type SummaryTimelineProps = {
reviewTimelineRef: RefObject<HTMLDivElement>; reviewTimelineRef: React.RefObject<HTMLDivElement>;
timelineStart: number; timelineStart: number;
timelineEnd: number; timelineEnd: number;
segmentDuration: number; segmentDuration: number;
@ -29,7 +32,6 @@ export function SummaryTimeline({
}: SummaryTimelineProps) { }: SummaryTimelineProps) {
const summaryTimelineRef = useRef<HTMLDivElement>(null); const summaryTimelineRef = useRef<HTMLDivElement>(null);
const visibleSectionRef = useRef<HTMLDivElement>(null); const visibleSectionRef = useRef<HTMLDivElement>(null);
const [segmentHeight, setSegmentHeight] = useState(0);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [scrollStartPosition, setScrollStartPosition] = useState<number>(0); const [scrollStartPosition, setScrollStartPosition] = useState<number>(0);
@ -43,61 +45,89 @@ export function SummaryTimeline({
[timelineEnd, timelineStart, segmentDuration], [timelineEnd, timelineStart, segmentDuration],
); );
const { alignStartDateToTimeline } = useTimelineUtils({ const { alignStartDateToTimeline, alignEndDateToTimeline } = useTimelineUtils(
{
segmentDuration, segmentDuration,
timelineDuration: reviewTimelineDuration, timelineDuration: reviewTimelineDuration,
timelineRef: reviewTimelineRef, timelineRef: reviewTimelineRef,
}); },
const timelineStartAligned = useMemo(
() => alignStartDateToTimeline(timelineStart) + 2 * segmentDuration,
[timelineStart, alignStartDateToTimeline, segmentDuration],
); );
// Generate segments for the timeline const consolidatedSegments = useMemo(() => {
const generateSegments = useCallback(() => { const filteredEvents = events.filter(
const segmentCount = Math.ceil(reviewTimelineDuration / segmentDuration); (event) => event.severity === severityType,
if (segmentHeight) {
return Array.from({ length: segmentCount }, (_, index) => {
const segmentTime = timelineStartAligned - index * segmentDuration;
return (
<SummarySegment
key={segmentTime}
events={events}
segmentDuration={segmentDuration}
segmentTime={segmentTime}
segmentHeight={segmentHeight}
severityType={severityType}
/>
); );
const sortedEvents = filteredEvents.sort(
(a, b) => a.start_time - b.start_time,
);
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,
}); });
} }
}, [
segmentDuration,
timelineStartAligned,
events,
reviewTimelineDuration,
segmentHeight,
severityType,
]);
const segments = useMemo( consolidated.push({
() => generateSegments(), startTime: alignedStartTime,
// we know that these deps are correct endTime: alignedEndTime,
// eslint-disable-next-line react-hooks/exhaustive-deps severity: event.severity,
[ reviewed: event.has_been_reviewed,
segmentDuration, });
segmentHeight,
timelineStartAligned, currentTime = alignedEndTime;
}
});
if (currentTime < timelineStartAligned) {
consolidated.push({
startTime: currentTime,
endTime: timelineStartAligned,
severity: "empty",
reviewed: false,
});
}
return consolidated.length > 0
? consolidated
: [
{
startTime: alignEndDateToTimeline(timelineEnd),
endTime: timelineStartAligned,
severity: "empty" as const,
reviewed: false,
},
];
}, [
events, events,
reviewTimelineDuration,
segmentHeight,
generateSegments,
severityType, severityType,
], timelineStart,
); timelineEnd,
alignStartDateToTimeline,
alignEndDateToTimeline,
segmentDuration,
]);
const setVisibleSectionStyles = useCallback(() => { const setVisibleSectionStyles = useCallback(() => {
if ( if (
@ -137,15 +167,6 @@ export function SummaryTimeline({
observer.current = new ResizeObserver(() => { observer.current = new ResizeObserver(() => {
setVisibleSectionStyles(); setVisibleSectionStyles();
if (summaryTimelineRef.current) {
const { clientHeight: summaryTimelineVisibleHeight } =
summaryTimelineRef.current;
setSegmentHeight(
summaryTimelineVisibleHeight /
(reviewTimelineDuration / segmentDuration),
);
}
}); });
observer.current.observe(content); observer.current.observe(content);
@ -163,18 +184,6 @@ export function SummaryTimeline({
segmentDuration, segmentDuration,
]); ]);
useEffect(() => {
if (summaryTimelineRef.current) {
const { clientHeight: summaryTimelineVisibleHeight } =
summaryTimelineRef.current;
setSegmentHeight(
summaryTimelineVisibleHeight /
(reviewTimelineDuration / segmentDuration),
);
}
}, [reviewTimelineDuration, summaryTimelineRef, segmentDuration]);
const timelineClick = useCallback( const timelineClick = useCallback(
( (
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>, e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,
@ -344,11 +353,17 @@ export function SummaryTimeline({
> >
<div <div
ref={summaryTimelineRef} ref={summaryTimelineRef}
className="relative z-10 flex h-full flex-col" className="relative z-10 flex h-full flex-col-reverse"
onClick={timelineClick} onClick={timelineClick}
onTouchEnd={timelineClick} onTouchEnd={timelineClick}
> >
{segments} {consolidatedSegments.map((segment, index) => (
<SummarySegment
key={`${segment.startTime}-${segment.endTime}+${index}`}
segmentData={segment}
totalDuration={timelineStart - timelineEnd}
/>
))}
</div> </div>
<div <div
ref={visibleSectionRef} ref={visibleSectionRef}

View 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>
);
},
);

View 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;

View File

@ -22,7 +22,7 @@ type TimestampSegmentProps = {
isLastSegmentInMinimap: boolean; isLastSegmentInMinimap: boolean;
timestamp: Date; timestamp: Date;
timestampSpread: number; timestampSpread: number;
segmentKey: number; segmentKey: string;
}; };
export function MinimapBounds({ export function MinimapBounds({

View File

@ -1,12 +1,4 @@
import { import { useCallback, useEffect, useMemo, useRef, useState } from "react";
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import { useTimelineUtils } from "./use-timeline-utils"; import { useTimelineUtils } from "./use-timeline-utils";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr"; import useSWR from "swr";
@ -33,7 +25,8 @@ type DraggableElementProps = {
setIsDragging: React.Dispatch<React.SetStateAction<boolean>>; setIsDragging: React.Dispatch<React.SetStateAction<boolean>>;
setDraggableElementPosition?: React.Dispatch<React.SetStateAction<number>>; setDraggableElementPosition?: React.Dispatch<React.SetStateAction<number>>;
dense: boolean; dense: boolean;
timelineSegments: ReactNode[]; segments: number[];
scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void;
}; };
function useDraggableElement({ function useDraggableElement({
@ -57,7 +50,8 @@ function useDraggableElement({
setIsDragging, setIsDragging,
setDraggableElementPosition, setDraggableElementPosition,
dense, dense,
timelineSegments, segments,
scrollToSegment,
}: DraggableElementProps) { }: DraggableElementProps) {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
@ -66,7 +60,6 @@ function useDraggableElement({
const [elementScrollIntoView, setElementScrollIntoView] = useState(true); const [elementScrollIntoView, setElementScrollIntoView] = useState(true);
const [scrollEdgeSize, setScrollEdgeSize] = useState<number>(); const [scrollEdgeSize, setScrollEdgeSize] = useState<number>();
const [fullTimelineHeight, setFullTimelineHeight] = useState<number>(); const [fullTimelineHeight, setFullTimelineHeight] = useState<number>();
const [segments, setSegments] = useState<HTMLDivElement[]>([]);
const { alignStartDateToTimeline, getCumulativeScrollTop, segmentHeight } = const { alignStartDateToTimeline, getCumulativeScrollTop, segmentHeight } =
useTimelineUtils({ useTimelineUtils({
segmentDuration: segmentDuration, segmentDuration: segmentDuration,
@ -201,11 +194,7 @@ function useDraggableElement({
draggableElementTimeRef.current.textContent = draggableElementTimeRef.current.textContent =
getFormattedTimestamp(segmentStartTime); getFormattedTimestamp(segmentStartTime);
if (scrollTimeline && !userInteracting) { if (scrollTimeline && !userInteracting) {
scrollIntoView(thumb, { scrollToSegment(segmentStartTime);
block: "center",
behavior: "smooth",
scrollMode: "if-needed",
});
} }
} }
}); });
@ -222,6 +211,7 @@ function useDraggableElement({
setDraggableElementPosition, setDraggableElementPosition,
getFormattedTimestamp, getFormattedTimestamp,
userInteracting, userInteracting,
scrollToSegment,
], ],
); );
@ -241,12 +231,6 @@ function useDraggableElement({
[contentRef, draggableElementRef, timelineRef, getClientYPosition], [contentRef, draggableElementRef, timelineRef, getClientYPosition],
); );
useEffect(() => {
if (timelineRef.current && timelineSegments.length) {
setSegments(Array.from(timelineRef.current.querySelectorAll(".segment")));
}
}, [timelineRef, timelineCollapsed, timelineSegments]);
useEffect(() => { useEffect(() => {
let animationFrameId: number | null = null; let animationFrameId: number | null = null;
@ -256,7 +240,7 @@ function useDraggableElement({
showDraggableElement && showDraggableElement &&
isDragging && isDragging &&
clientYPosition && clientYPosition &&
segments && segments.length > 0 &&
fullTimelineHeight fullTimelineHeight
) { ) {
const { scrollTop: scrolled } = timelineRef.current; const { scrollTop: scrolled } = timelineRef.current;
@ -295,31 +279,18 @@ function useDraggableElement({
return; return;
} }
let targetSegmentId = 0; const start = Math.max(0, Math.floor(scrolled / segmentHeight));
let offset = 0;
segments.forEach((segmentElement: HTMLDivElement) => { const relativePosition = newElementPosition - scrolled;
const rect = segmentElement.getBoundingClientRect(); const segmentIndex =
const segmentTop = Math.floor(relativePosition / segmentHeight) + start + 1;
rect.top + scrolled - timelineTopAbsolute - segmentHeight;
const segmentBottom =
rect.bottom + scrolled - timelineTopAbsolute - segmentHeight;
// Check if handlebar position falls within the segment bounds const targetSegmentTime = segments[segmentIndex];
if ( if (targetSegmentTime === undefined) return;
newElementPosition >= segmentTop &&
newElementPosition <= segmentBottom const segmentStart = segmentIndex * segmentHeight - scrolled;
) {
targetSegmentId = parseFloat( const offset = Math.min(segmentStart - relativePosition, segmentHeight);
segmentElement.getAttribute("data-segment-id") || "0",
);
offset = Math.min(
segmentBottom - newElementPosition,
segmentHeight,
);
return;
}
});
if ((draggingAtTopEdge || draggingAtBottomEdge) && scrollEdgeSize) { if ((draggingAtTopEdge || draggingAtBottomEdge) && scrollEdgeSize) {
if (draggingAtTopEdge) { if (draggingAtTopEdge) {
@ -349,8 +320,8 @@ function useDraggableElement({
} }
const setTime = alignSetTimeToSegment const setTime = alignSetTimeToSegment
? targetSegmentId ? targetSegmentTime
: targetSegmentId + segmentDuration * (offset / segmentHeight); : targetSegmentTime + segmentDuration * (offset / segmentHeight);
updateDraggableElementPosition( updateDraggableElementPosition(
newElementPosition, newElementPosition,
@ -361,7 +332,7 @@ function useDraggableElement({
if (setDraggableElementTime) { if (setDraggableElementTime) {
setDraggableElementTime( setDraggableElementTime(
targetSegmentId + segmentDuration * (offset / segmentHeight), targetSegmentTime + segmentDuration * (offset / segmentHeight),
); );
} }
@ -397,6 +368,7 @@ function useDraggableElement({
draggingAtTopEdge, draggingAtTopEdge,
draggingAtBottomEdge, draggingAtBottomEdge,
showDraggableElement, showDraggableElement,
segments,
]); ]);
useEffect(() => { useEffect(() => {
@ -408,24 +380,23 @@ function useDraggableElement({
!isDragging && !isDragging &&
segments.length > 0 segments.length > 0
) { ) {
const { scrollTop: scrolled } = timelineRef.current;
const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime); const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime);
if (!userInteracting) {
scrollToSegment(alignedSegmentTime);
}
const segmentElement = timelineRef.current.querySelector( const segmentIndex = segments.findIndex(
`[data-segment-id="${alignedSegmentTime}"]`, (time) => time === alignedSegmentTime,
); );
if (segmentElement) { if (segmentIndex >= 0) {
const timelineRect = timelineRef.current.getBoundingClientRect(); const segmentStart = segmentIndex * segmentHeight;
const timelineTopAbsolute = timelineRect.top;
const rect = segmentElement.getBoundingClientRect();
const segmentTop = rect.top + scrolled - timelineTopAbsolute;
const offset = const offset =
((draggableElementTime - alignedSegmentTime) / segmentDuration) * ((draggableElementTime - alignedSegmentTime) / segmentDuration) *
segmentHeight; segmentHeight;
// subtract half the height of the handlebar cross bar (4px) for pixel perfection // subtract half the height of the handlebar cross bar (4px) for pixel perfection
const newElementPosition = segmentTop - offset - 2; const newElementPosition = segmentStart - offset - 2;
updateDraggableElementPosition( updateDraggableElementPosition(
newElementPosition, newElementPosition,
@ -454,14 +425,27 @@ function useDraggableElement({
segments, segments,
]); ]);
const findNextAvailableSegment = useCallback(
(startTime: number) => {
let searchTime = startTime;
while (searchTime < timelineStartAligned + timelineDuration) {
if (segments.includes(searchTime)) {
return searchTime;
}
searchTime += segmentDuration;
}
return null;
},
[segments, timelineStartAligned, timelineDuration, segmentDuration],
);
useEffect(() => { useEffect(() => {
if ( if (
timelineRef.current && timelineRef.current &&
segmentsRef.current && segmentsRef.current &&
draggableElementTime && draggableElementTime &&
timelineCollapsed && timelineCollapsed &&
timelineSegments && segments.length > 0
segments
) { ) {
setFullTimelineHeight( setFullTimelineHeight(
Math.min( Math.min(
@ -469,47 +453,27 @@ function useDraggableElement({
segmentsRef.current.scrollHeight, segmentsRef.current.scrollHeight,
), ),
); );
const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime); const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime);
let segmentElement = timelineRef.current.querySelector( if (segments.includes(alignedSegmentTime)) {
`[data-segment-id="${alignedSegmentTime}"]`, scrollToSegment(alignedSegmentTime);
); } else {
if (!segmentElement) {
// segment not found, maybe we collapsed over a collapsible segment // segment not found, maybe we collapsed over a collapsible segment
let searchTime = alignedSegmentTime; const nextAvailableSegment =
findNextAvailableSegment(alignedSegmentTime);
while ( if (nextAvailableSegment !== null) {
searchTime < timelineStartAligned && scrollToSegment(nextAvailableSegment);
searchTime < timelineStartAligned + timelineDuration
) {
searchTime += segmentDuration;
segmentElement = timelineRef.current.querySelector(
`[data-segment-id="${searchTime}"]`,
);
if (segmentElement) {
// found, set time
if (setDraggableElementTime) { if (setDraggableElementTime) {
setDraggableElementTime(searchTime); setDraggableElementTime(nextAvailableSegment);
}
return;
}
}
}
if (!segmentElement) {
// segment still not found, just start at the beginning of the timeline or at now()
if (segments?.length) {
const searchTime = parseInt(
segments[0].getAttribute("data-segment-id") || "0",
10,
);
if (setDraggableElementTime) {
setDraggableElementTime(searchTime);
} }
} else { } else {
// segment still not found, just start at the beginning of the timeline or at now()
const firstAvailableSegment = segments[0] || timelineStartAligned;
scrollToSegment(firstAvailableSegment);
if (setDraggableElementTime) { if (setDraggableElementTime) {
setDraggableElementTime(timelineStartAligned); setDraggableElementTime(firstAvailableSegment);
} }
} }
} }

View File

@ -0,0 +1,174 @@
import { useState, useEffect, useCallback, useRef } from "react";
import { TimelineZoomDirection } from "@/types/review";
type ZoomSettings = {
segmentDuration: number;
timestampSpread: number;
};
type UseTimelineZoomProps = {
zoomSettings: ZoomSettings;
zoomLevels: ZoomSettings[];
onZoomChange: (newZoomLevel: number) => void;
pinchThresholdPercent?: number;
timelineRef: React.RefObject<HTMLDivElement>;
timelineDuration: number;
};
export function useTimelineZoom({
zoomSettings,
zoomLevels,
onZoomChange,
pinchThresholdPercent = 20,
timelineRef,
timelineDuration,
}: UseTimelineZoomProps) {
const [zoomLevel, setZoomLevel] = useState(
zoomLevels.findIndex(
(level) =>
level.segmentDuration === zoomSettings.segmentDuration &&
level.timestampSpread === zoomSettings.timestampSpread,
),
);
const [isZooming, setIsZooming] = useState(false);
const [zoomDirection, setZoomDirection] =
useState<TimelineZoomDirection>(null);
const touchStartDistanceRef = useRef(0);
const getPinchThreshold = useCallback(() => {
return (window.innerHeight * pinchThresholdPercent) / 100;
}, [pinchThresholdPercent]);
const wheelDeltaRef = useRef(0);
const isZoomingRef = useRef(false);
const debounceTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const handleZoom = useCallback(
(delta: number) => {
setIsZooming(true);
setZoomDirection(delta > 0 ? "out" : "in");
setZoomLevel((prevLevel) => {
const newLevel = Math.max(
0,
Math.min(zoomLevels.length - 1, prevLevel - delta),
);
if (newLevel !== prevLevel && timelineRef.current) {
const { scrollTop, clientHeight, scrollHeight } = timelineRef.current;
// get time at the center of the viewable timeline
const centerRatio = (scrollTop + clientHeight / 2) / scrollHeight;
const centerTime = centerRatio * timelineDuration;
// calc the new total height based on the new zoom level
const newTotalHeight =
(timelineDuration / zoomLevels[newLevel].segmentDuration) * 8;
// calc the new scroll position to keep the center time in view
const newScrollTop =
(centerTime / timelineDuration) * newTotalHeight - clientHeight / 2;
onZoomChange(newLevel);
// Apply new scroll position after a short delay to allow for DOM update
setTimeout(() => {
if (timelineRef.current) {
timelineRef.current.scrollTop = newScrollTop;
}
}, 0);
}
return newLevel;
});
setTimeout(() => {
setIsZooming(false);
setZoomDirection(null);
}, 500);
},
[zoomLevels, onZoomChange, timelineRef, timelineDuration],
);
const debouncedZoom = useCallback(() => {
if (Math.abs(wheelDeltaRef.current) >= 200) {
handleZoom(wheelDeltaRef.current > 0 ? 1 : -1);
wheelDeltaRef.current = 0;
isZoomingRef.current = false;
} else {
isZoomingRef.current = false;
}
}, [handleZoom]);
const handleWheel = useCallback(
(event: WheelEvent) => {
if (event.ctrlKey) {
event.preventDefault();
if (!isZoomingRef.current) {
wheelDeltaRef.current += event.deltaY;
if (Math.abs(wheelDeltaRef.current) >= 200) {
isZoomingRef.current = true;
if (debounceTimeoutRef.current) {
clearTimeout(debounceTimeoutRef.current);
}
debounceTimeoutRef.current = setTimeout(() => {
debouncedZoom();
}, 200);
}
}
}
},
[debouncedZoom],
);
const handleTouchStart = useCallback((event: TouchEvent) => {
if (event.touches.length === 2) {
event.preventDefault();
const touch1 = event.touches[0];
const touch2 = event.touches[1];
const distance = Math.hypot(
touch1.clientX - touch2.clientX,
touch1.clientY - touch2.clientY,
);
touchStartDistanceRef.current = distance;
}
}, []);
const handleTouchMove = useCallback(
(event: TouchEvent) => {
if (event.touches.length === 2) {
event.preventDefault();
const touch1 = event.touches[0];
const touch2 = event.touches[1];
const currentDistance = Math.hypot(
touch1.clientX - touch2.clientX,
touch1.clientY - touch2.clientY,
);
const distanceDelta = currentDistance - touchStartDistanceRef.current;
const pinchThreshold = getPinchThreshold();
if (Math.abs(distanceDelta) > pinchThreshold) {
handleZoom(distanceDelta > 0 ? -1 : 1);
touchStartDistanceRef.current = currentDistance;
}
}
},
[handleZoom, getPinchThreshold],
);
useEffect(() => {
window.addEventListener("wheel", handleWheel, { passive: false });
window.addEventListener("touchstart", handleTouchStart, { passive: false });
window.addEventListener("touchmove", handleTouchMove, { passive: false });
return () => {
window.removeEventListener("wheel", handleWheel);
window.removeEventListener("touchstart", handleTouchStart);
window.removeEventListener("touchmove", handleTouchMove);
};
}, [handleWheel, handleTouchStart, handleTouchMove]);
return { zoomLevel, handleZoom, isZooming, zoomDirection };
}

View File

@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from "react"; import { useCallback, useMemo, useRef, useState } from "react";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
@ -29,6 +29,7 @@ import { useNavigate } from "react-router-dom";
import SummaryTimeline from "@/components/timeline/SummaryTimeline"; import SummaryTimeline from "@/components/timeline/SummaryTimeline";
import { isMobile } from "react-device-detect"; import { isMobile } from "react-device-detect";
import IconPicker, { IconElement } from "@/components/icons/IconPicker"; import IconPicker, { IconElement } from "@/components/icons/IconPicker";
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
// Color data // Color data
const colors = [ const colors = [
@ -173,32 +174,37 @@ function UIPlayground() {
return Math.floor(Date.now() / 1000); // Default to current time if no events return Math.floor(Date.now() / 1000); // Default to current time if no events
}, [mockEvents]); }, [mockEvents]);
const [zoomLevel, setZoomLevel] = useState(0);
const [zoomSettings, setZoomSettings] = useState({ const [zoomSettings, setZoomSettings] = useState({
segmentDuration: 60, segmentDuration: 60,
timestampSpread: 15, timestampSpread: 15,
}); });
const possibleZoomLevels = [ const possibleZoomLevels = useMemo(
() => [
{ segmentDuration: 60, timestampSpread: 15 }, { segmentDuration: 60, timestampSpread: 15 },
{ segmentDuration: 30, timestampSpread: 5 }, { segmentDuration: 30, timestampSpread: 5 },
{ segmentDuration: 10, timestampSpread: 1 }, { segmentDuration: 10, timestampSpread: 1 },
]; ],
[],
function handleZoomIn() {
const nextZoomLevel = Math.min(
possibleZoomLevels.length - 1,
zoomLevel + 1,
); );
setZoomLevel(nextZoomLevel);
setZoomSettings(possibleZoomLevels[nextZoomLevel]);
}
function handleZoomOut() { const handleZoomChange = useCallback(
const nextZoomLevel = Math.max(0, zoomLevel - 1); (newZoomLevel: number) => {
setZoomLevel(nextZoomLevel); setZoomSettings(possibleZoomLevels[newZoomLevel]);
setZoomSettings(possibleZoomLevels[nextZoomLevel]); },
} [possibleZoomLevels],
);
const { zoomLevel, handleZoom, isZooming, zoomDirection } = useTimelineZoom({
zoomSettings,
zoomLevels: possibleZoomLevels,
onZoomChange: handleZoomChange,
timelineRef: reviewTimelineRef,
timelineDuration: 4 * 60 * 60,
});
const handleZoomIn = () => handleZoom(-1);
const handleZoomOut = () => handleZoom(1);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
@ -330,6 +336,7 @@ function UIPlayground() {
<div className="my-4 w-[40px]"> <div className="my-4 w-[40px]">
<CameraActivityIndicator /> <CameraActivityIndicator />
</div> </div>
zoom level: {zoomLevel}
<p> <p>
<Button <Button
variant="default" variant="default"
@ -367,7 +374,6 @@ function UIPlayground() {
</a>{" "} </a>{" "}
for usage. for usage.
</p> </p>
<div className="my-5"> <div className="my-5">
{colors.map((color, index) => ( {colors.map((color, index) => (
<ColorSwatch <ColorSwatch
@ -401,9 +407,10 @@ function UIPlayground() {
setExportEndTime={setExportEndTime} setExportEndTime={setExportEndTime}
events={mockEvents} // events, including new has_been_reviewed and severity properties events={mockEvents} // events, including new has_been_reviewed and severity properties
motion_events={mockMotionData} motion_events={mockMotionData}
severityType={"alert"} // choose the severity type for the middle line - all other severity types are to the right
contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later
dense={isMobile} // dense will produce a smaller handlebar and only minute resolution on timestamps dense={isMobile} // dense will produce a smaller handlebar and only minute resolution on timestamps
isZooming={isZooming} // is the timeline actively zooming?
zoomDirection={zoomDirection} // is the timeline zooming in or out
/> />
)} )}
{isEventsReviewTimeline && ( {isEventsReviewTimeline && (
@ -429,6 +436,8 @@ function UIPlayground() {
contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later
timelineRef={reviewTimelineRef} // save a ref to this timeline to connect with the summary timeline timelineRef={reviewTimelineRef} // save a ref to this timeline to connect with the summary timeline
dense // dense will produce a smaller handlebar and only minute resolution on timestamps dense // dense will produce a smaller handlebar and only minute resolution on timestamps
isZooming={isZooming} // is the timeline actively zooming?
zoomDirection={zoomDirection} // is the timeline zooming in or out
/> />
)} )}
</div> </div>

View File

@ -60,3 +60,12 @@ export type MotionData = {
export const REVIEW_PADDING = 4; export const REVIEW_PADDING = 4;
export type ReviewDetailPaneType = "overview" | "details"; export type ReviewDetailPaneType = "overview" | "details";
export type ConsolidatedSegmentData = {
startTime: number;
endTime: number;
severity: ReviewSeverity | "empty";
reviewed: boolean;
};
export type TimelineZoomDirection = "in" | "out" | null;

View File

@ -53,6 +53,7 @@ import { FilterList, LAST_24_HOURS_KEY } from "@/types/filter";
import { GiSoundWaves } from "react-icons/gi"; import { GiSoundWaves } from "react-icons/gi";
import useKeyboardListener from "@/hooks/use-keyboard-listener"; import useKeyboardListener from "@/hooks/use-keyboard-listener";
import ReviewDetailDialog from "@/components/overlay/detail/ReviewDetailDialog"; import ReviewDetailDialog from "@/components/overlay/detail/ReviewDetailDialog";
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
type EventViewProps = { type EventViewProps = {
reviewItems?: SegmentedReviewData; reviewItems?: SegmentedReviewData;
@ -461,8 +462,6 @@ function DetectionReview({
}: DetectionReviewProps) { }: DetectionReviewProps) {
const reviewTimelineRef = useRef<HTMLDivElement>(null); const reviewTimelineRef = useRef<HTMLDivElement>(null);
const segmentDuration = 60;
// detail // detail
const [reviewDetail, setReviewDetail] = useState<ReviewSegment>(); const [reviewDetail, setReviewDetail] = useState<ReviewSegment>();
@ -492,9 +491,38 @@ function DetectionReview({
[timeRange], [timeRange],
); );
const [zoomSettings, setZoomSettings] = useState({
segmentDuration: 60,
timestampSpread: 15,
});
const possibleZoomLevels = useMemo(
() => [
{ segmentDuration: 60, timestampSpread: 15 },
{ segmentDuration: 30, timestampSpread: 5 },
{ segmentDuration: 10, timestampSpread: 1 },
],
[],
);
const handleZoomChange = useCallback(
(newZoomLevel: number) => {
setZoomSettings(possibleZoomLevels[newZoomLevel]);
},
[possibleZoomLevels],
);
const { isZooming, zoomDirection } = useTimelineZoom({
zoomSettings,
zoomLevels: possibleZoomLevels,
onZoomChange: handleZoomChange,
timelineRef: reviewTimelineRef,
timelineDuration,
});
const { alignStartDateToTimeline, getVisibleTimelineDuration } = const { alignStartDateToTimeline, getVisibleTimelineDuration } =
useTimelineUtils({ useTimelineUtils({
segmentDuration, segmentDuration: zoomSettings.segmentDuration,
timelineDuration, timelineDuration,
timelineRef: reviewTimelineRef, timelineRef: reviewTimelineRef,
}); });
@ -692,7 +720,7 @@ function DetectionReview({
data-start={value.start_time} data-start={value.start_time}
data-segment-start={ data-segment-start={
alignStartDateToTimeline(value.start_time) - alignStartDateToTimeline(value.start_time) -
segmentDuration zoomSettings.segmentDuration
} }
className="review-item relative rounded-lg" className="review-item relative rounded-lg"
> >
@ -749,13 +777,13 @@ function DetectionReview({
</div> </div>
</div> </div>
<div className="flex w-[65px] flex-row md:w-[110px]"> <div className="flex w-[65px] flex-row md:w-[110px]">
<div className="no-scrollbar w-[55px] overflow-y-auto md:w-[100px]"> <div className="no-scrollbar w-[55px] md:w-[100px]">
{loading ? ( {loading ? (
<Skeleton className="size-full" /> <Skeleton className="size-full" />
) : ( ) : (
<EventReviewTimeline <EventReviewTimeline
segmentDuration={segmentDuration} segmentDuration={zoomSettings.segmentDuration}
timestampSpread={15} timestampSpread={zoomSettings.timestampSpread}
timelineStart={timeRange.before} timelineStart={timeRange.before}
timelineEnd={timeRange.after} timelineEnd={timeRange.after}
showMinimap={showMinimap && !previewTime} showMinimap={showMinimap && !previewTime}
@ -769,6 +797,8 @@ function DetectionReview({
contentRef={contentRef} contentRef={contentRef}
timelineRef={reviewTimelineRef} timelineRef={reviewTimelineRef}
dense={isMobile} dense={isMobile}
isZooming={isZooming}
zoomDirection={zoomDirection}
/> />
)} )}
</div> </div>
@ -780,7 +810,7 @@ function DetectionReview({
reviewTimelineRef={reviewTimelineRef} reviewTimelineRef={reviewTimelineRef}
timelineStart={timeRange.before} timelineStart={timeRange.before}
timelineEnd={timeRange.after} timelineEnd={timeRange.after}
segmentDuration={segmentDuration} segmentDuration={zoomSettings.segmentDuration}
events={reviewItems?.all ?? []} events={reviewItems?.all ?? []}
severityType={severity} severityType={severity}
/> />
@ -1095,7 +1125,6 @@ function MotionReview({
setHandlebarTime={setCurrentTime} setHandlebarTime={setCurrentTime}
events={reviewItems?.all ?? []} events={reviewItems?.all ?? []}
motion_events={motionData ?? []} motion_events={motionData ?? []}
severityType="significant_motion"
contentRef={contentRef} contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => { onHandlebarDraggingChange={(scrubbing) => {
if (playing && scrubbing) { if (playing && scrubbing) {
@ -1105,6 +1134,8 @@ function MotionReview({
setScrubbing(scrubbing); setScrubbing(scrubbing);
}} }}
dense={isMobileOnly} dense={isMobileOnly}
isZooming={false}
zoomDirection={null}
/> />
) : ( ) : (
<Skeleton className="size-full" /> <Skeleton className="size-full" />

View File

@ -46,8 +46,7 @@ import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
import { useResizeObserver } from "@/hooks/resize-observer"; import { useResizeObserver } from "@/hooks/resize-observer";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useFullscreen } from "@/hooks/use-fullscreen"; import { useFullscreen } from "@/hooks/use-fullscreen";
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
const SEGMENT_DURATION = 30;
type RecordingViewProps = { type RecordingViewProps = {
startCamera: string; startCamera: string;
@ -633,6 +632,7 @@ export function RecordingView({
type TimelineProps = { type TimelineProps = {
contentRef: MutableRefObject<HTMLDivElement | null>; contentRef: MutableRefObject<HTMLDivElement | null>;
timelineRef?: MutableRefObject<HTMLDivElement | null>;
mainCamera: string; mainCamera: string;
timelineType: TimelineType; timelineType: TimelineType;
timeRange: TimeRange; timeRange: TimeRange;
@ -646,6 +646,7 @@ type TimelineProps = {
}; };
function Timeline({ function Timeline({
contentRef, contentRef,
timelineRef,
mainCamera, mainCamera,
timelineType, timelineType,
timeRange, timeRange,
@ -657,12 +658,48 @@ function Timeline({
setScrubbing, setScrubbing,
setExportRange, setExportRange,
}: TimelineProps) { }: TimelineProps) {
const { data: motionData } = useSWR<MotionData[]>([ const internalTimelineRef = useRef<HTMLDivElement>(null);
const selectedTimelineRef = timelineRef || internalTimelineRef;
// timeline interaction
const [zoomSettings, setZoomSettings] = useState({
segmentDuration: 30,
timestampSpread: 15,
});
const possibleZoomLevels = useMemo(
() => [
{ segmentDuration: 30, timestampSpread: 15 },
{ segmentDuration: 15, timestampSpread: 5 },
{ segmentDuration: 5, timestampSpread: 1 },
],
[],
);
const handleZoomChange = useCallback(
(newZoomLevel: number) => {
setZoomSettings(possibleZoomLevels[newZoomLevel]);
},
[possibleZoomLevels],
);
const { isZooming, zoomDirection } = useTimelineZoom({
zoomSettings,
zoomLevels: possibleZoomLevels,
onZoomChange: handleZoomChange,
timelineRef: selectedTimelineRef,
timelineDuration: timeRange.after - timeRange.before,
});
// motion data
const { data: motionData, isLoading } = useSWR<MotionData[]>([
"review/activity/motion", "review/activity/motion",
{ {
before: timeRange.before, before: timeRange.before,
after: timeRange.after, after: timeRange.after,
scale: SEGMENT_DURATION / 2, scale: Math.round(zoomSettings.segmentDuration / 2),
cameras: mainCamera, cameras: mainCamera,
}, },
]); ]);
@ -695,10 +732,11 @@ function Timeline({
<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 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> <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>
{timelineType == "timeline" ? ( {timelineType == "timeline" ? (
motionData ? ( !isLoading ? (
<MotionReviewTimeline <MotionReviewTimeline
segmentDuration={30} timelineRef={selectedTimelineRef}
timestampSpread={15} segmentDuration={zoomSettings.segmentDuration}
timestampSpread={zoomSettings.timestampSpread}
timelineStart={timeRange.before} timelineStart={timeRange.before}
timelineEnd={timeRange.after} timelineEnd={timeRange.after}
showHandlebar={exportRange == undefined} showHandlebar={exportRange == undefined}
@ -711,9 +749,10 @@ function Timeline({
setHandlebarTime={setCurrentTime} setHandlebarTime={setCurrentTime}
events={mainCameraReviewItems} events={mainCameraReviewItems}
motion_events={motionData ?? []} motion_events={motionData ?? []}
severityType="significant_motion"
contentRef={contentRef} contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)} onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
isZooming={isZooming}
zoomDirection={zoomDirection}
/> />
) : ( ) : (
<Skeleton className="size-full" /> <Skeleton className="size-full" />

View File

@ -32,6 +32,8 @@ module.exports = {
scale2: "scale2 3s ease-in-out infinite", scale2: "scale2 3s ease-in-out infinite",
scale3: "scale3 3s ease-in-out infinite", scale3: "scale3 3s ease-in-out infinite",
scale4: "scale4 3s ease-in-out infinite", scale4: "scale4 3s ease-in-out infinite",
"timeline-zoom-in": "timeline-zoom-in 0.3s ease-out",
"timeline-zoom-out": "timeline-zoom-out 0.3s ease-out",
}, },
aspectRatio: { aspectRatio: {
wide: "32 / 9", wide: "32 / 9",
@ -140,6 +142,16 @@ module.exports = {
"40%, 60%": { transform: "scale(1.4)" }, "40%, 60%": { transform: "scale(1.4)" },
"30%, 70%": { transform: "scale(1)" }, "30%, 70%": { transform: "scale(1)" },
}, },
"timeline-zoom-in": {
"0%": { transform: "translateY(0)", opacity: "1" },
"50%": { transform: "translateY(0%)", opacity: "0.5" },
"100%": { transform: "translateY(0)", opacity: "1" },
},
"timeline-zoom-out": {
"0%": { transform: "translateY(0)", opacity: "1" },
"50%": { transform: "translateY(0%)", opacity: "0.5" },
"100%": { transform: "translateY(0)", opacity: "1" },
},
}, },
screens: { screens: {
xs: "480px", xs: "480px",