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

View File

@ -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)}

View File

@ -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>
);
}

View File

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

View File

@ -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

View File

@ -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>
);
}

View File

@ -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}

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;
timestamp: Date;
timestampSpread: number;
segmentKey: number;
segmentKey: string;
};
export function MinimapBounds({

View File

@ -1,12 +1,4 @@
import {
ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import scrollIntoView from "scroll-into-view-if-needed";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTimelineUtils } from "./use-timeline-utils";
import { FrigateConfig } from "@/types/frigateConfig";
import useSWR from "swr";
@ -33,7 +25,8 @@ type DraggableElementProps = {
setIsDragging: React.Dispatch<React.SetStateAction<boolean>>;
setDraggableElementPosition?: React.Dispatch<React.SetStateAction<number>>;
dense: boolean;
timelineSegments: ReactNode[];
segments: number[];
scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void;
};
function useDraggableElement({
@ -57,7 +50,8 @@ function useDraggableElement({
setIsDragging,
setDraggableElementPosition,
dense,
timelineSegments,
segments,
scrollToSegment,
}: DraggableElementProps) {
const { data: config } = useSWR<FrigateConfig>("config");
@ -66,7 +60,6 @@ function useDraggableElement({
const [elementScrollIntoView, setElementScrollIntoView] = useState(true);
const [scrollEdgeSize, setScrollEdgeSize] = useState<number>();
const [fullTimelineHeight, setFullTimelineHeight] = useState<number>();
const [segments, setSegments] = useState<HTMLDivElement[]>([]);
const { alignStartDateToTimeline, getCumulativeScrollTop, segmentHeight } =
useTimelineUtils({
segmentDuration: segmentDuration,
@ -201,11 +194,7 @@ function useDraggableElement({
draggableElementTimeRef.current.textContent =
getFormattedTimestamp(segmentStartTime);
if (scrollTimeline && !userInteracting) {
scrollIntoView(thumb, {
block: "center",
behavior: "smooth",
scrollMode: "if-needed",
});
scrollToSegment(segmentStartTime);
}
}
});
@ -222,6 +211,7 @@ function useDraggableElement({
setDraggableElementPosition,
getFormattedTimestamp,
userInteracting,
scrollToSegment,
],
);
@ -241,12 +231,6 @@ function useDraggableElement({
[contentRef, draggableElementRef, timelineRef, getClientYPosition],
);
useEffect(() => {
if (timelineRef.current && timelineSegments.length) {
setSegments(Array.from(timelineRef.current.querySelectorAll(".segment")));
}
}, [timelineRef, timelineCollapsed, timelineSegments]);
useEffect(() => {
let animationFrameId: number | null = null;
@ -256,7 +240,7 @@ function useDraggableElement({
showDraggableElement &&
isDragging &&
clientYPosition &&
segments &&
segments.length > 0 &&
fullTimelineHeight
) {
const { scrollTop: scrolled } = timelineRef.current;
@ -295,31 +279,18 @@ function useDraggableElement({
return;
}
let targetSegmentId = 0;
let offset = 0;
const start = Math.max(0, Math.floor(scrolled / segmentHeight));
segments.forEach((segmentElement: HTMLDivElement) => {
const rect = segmentElement.getBoundingClientRect();
const segmentTop =
rect.top + scrolled - timelineTopAbsolute - segmentHeight;
const segmentBottom =
rect.bottom + scrolled - timelineTopAbsolute - segmentHeight;
const relativePosition = newElementPosition - scrolled;
const segmentIndex =
Math.floor(relativePosition / segmentHeight) + start + 1;
// Check if handlebar position falls within the segment bounds
if (
newElementPosition >= segmentTop &&
newElementPosition <= segmentBottom
) {
targetSegmentId = parseFloat(
segmentElement.getAttribute("data-segment-id") || "0",
);
offset = Math.min(
segmentBottom - newElementPosition,
segmentHeight,
);
return;
}
});
const targetSegmentTime = segments[segmentIndex];
if (targetSegmentTime === undefined) return;
const segmentStart = segmentIndex * segmentHeight - scrolled;
const offset = Math.min(segmentStart - relativePosition, segmentHeight);
if ((draggingAtTopEdge || draggingAtBottomEdge) && scrollEdgeSize) {
if (draggingAtTopEdge) {
@ -349,8 +320,8 @@ function useDraggableElement({
}
const setTime = alignSetTimeToSegment
? targetSegmentId
: targetSegmentId + segmentDuration * (offset / segmentHeight);
? targetSegmentTime
: targetSegmentTime + segmentDuration * (offset / segmentHeight);
updateDraggableElementPosition(
newElementPosition,
@ -361,7 +332,7 @@ function useDraggableElement({
if (setDraggableElementTime) {
setDraggableElementTime(
targetSegmentId + segmentDuration * (offset / segmentHeight),
targetSegmentTime + segmentDuration * (offset / segmentHeight),
);
}
@ -397,6 +368,7 @@ function useDraggableElement({
draggingAtTopEdge,
draggingAtBottomEdge,
showDraggableElement,
segments,
]);
useEffect(() => {
@ -408,24 +380,23 @@ function useDraggableElement({
!isDragging &&
segments.length > 0
) {
const { scrollTop: scrolled } = timelineRef.current;
const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime);
if (!userInteracting) {
scrollToSegment(alignedSegmentTime);
}
const segmentElement = timelineRef.current.querySelector(
`[data-segment-id="${alignedSegmentTime}"]`,
const segmentIndex = segments.findIndex(
(time) => time === alignedSegmentTime,
);
if (segmentElement) {
const timelineRect = timelineRef.current.getBoundingClientRect();
const timelineTopAbsolute = timelineRect.top;
const rect = segmentElement.getBoundingClientRect();
const segmentTop = rect.top + scrolled - timelineTopAbsolute;
if (segmentIndex >= 0) {
const segmentStart = segmentIndex * segmentHeight;
const offset =
((draggableElementTime - alignedSegmentTime) / segmentDuration) *
segmentHeight;
// subtract half the height of the handlebar cross bar (4px) for pixel perfection
const newElementPosition = segmentTop - offset - 2;
const newElementPosition = segmentStart - offset - 2;
updateDraggableElementPosition(
newElementPosition,
@ -454,14 +425,27 @@ function useDraggableElement({
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(() => {
if (
timelineRef.current &&
segmentsRef.current &&
draggableElementTime &&
timelineCollapsed &&
timelineSegments &&
segments
segments.length > 0
) {
setFullTimelineHeight(
Math.min(
@ -469,47 +453,27 @@ function useDraggableElement({
segmentsRef.current.scrollHeight,
),
);
const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime);
let segmentElement = timelineRef.current.querySelector(
`[data-segment-id="${alignedSegmentTime}"]`,
);
if (!segmentElement) {
if (segments.includes(alignedSegmentTime)) {
scrollToSegment(alignedSegmentTime);
} else {
// segment not found, maybe we collapsed over a collapsible segment
let searchTime = alignedSegmentTime;
const nextAvailableSegment =
findNextAvailableSegment(alignedSegmentTime);
while (
searchTime < timelineStartAligned &&
searchTime < timelineStartAligned + timelineDuration
) {
searchTime += segmentDuration;
segmentElement = timelineRef.current.querySelector(
`[data-segment-id="${searchTime}"]`,
);
if (segmentElement) {
// found, set time
if (setDraggableElementTime) {
setDraggableElementTime(searchTime);
}
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 (nextAvailableSegment !== null) {
scrollToSegment(nextAvailableSegment);
if (setDraggableElementTime) {
setDraggableElementTime(searchTime);
setDraggableElementTime(nextAvailableSegment);
}
} 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) {
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 useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
@ -29,6 +29,7 @@ import { useNavigate } from "react-router-dom";
import SummaryTimeline from "@/components/timeline/SummaryTimeline";
import { isMobile } from "react-device-detect";
import IconPicker, { IconElement } from "@/components/icons/IconPicker";
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
// Color data
const colors = [
@ -173,32 +174,37 @@ function UIPlayground() {
return Math.floor(Date.now() / 1000); // Default to current time if no events
}, [mockEvents]);
const [zoomLevel, setZoomLevel] = useState(0);
const [zoomSettings, setZoomSettings] = useState({
segmentDuration: 60,
timestampSpread: 15,
});
const possibleZoomLevels = [
{ segmentDuration: 60, timestampSpread: 15 },
{ segmentDuration: 30, timestampSpread: 5 },
{ segmentDuration: 10, timestampSpread: 1 },
];
const possibleZoomLevels = useMemo(
() => [
{ segmentDuration: 60, timestampSpread: 15 },
{ segmentDuration: 30, timestampSpread: 5 },
{ segmentDuration: 10, timestampSpread: 1 },
],
[],
);
function handleZoomIn() {
const nextZoomLevel = Math.min(
possibleZoomLevels.length - 1,
zoomLevel + 1,
);
setZoomLevel(nextZoomLevel);
setZoomSettings(possibleZoomLevels[nextZoomLevel]);
}
const handleZoomChange = useCallback(
(newZoomLevel: number) => {
setZoomSettings(possibleZoomLevels[newZoomLevel]);
},
[possibleZoomLevels],
);
function handleZoomOut() {
const nextZoomLevel = Math.max(0, zoomLevel - 1);
setZoomLevel(nextZoomLevel);
setZoomSettings(possibleZoomLevels[nextZoomLevel]);
}
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);
@ -330,6 +336,7 @@ function UIPlayground() {
<div className="my-4 w-[40px]">
<CameraActivityIndicator />
</div>
zoom level: {zoomLevel}
<p>
<Button
variant="default"
@ -367,7 +374,6 @@ function UIPlayground() {
</a>{" "}
for usage.
</p>
<div className="my-5">
{colors.map((color, index) => (
<ColorSwatch
@ -401,9 +407,10 @@ function UIPlayground() {
setExportEndTime={setExportEndTime}
events={mockEvents} // events, including new has_been_reviewed and severity properties
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
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 && (
@ -429,6 +436,8 @@ function UIPlayground() {
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
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>

View File

@ -60,3 +60,12 @@ export type MotionData = {
export const REVIEW_PADDING = 4;
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 useKeyboardListener from "@/hooks/use-keyboard-listener";
import ReviewDetailDialog from "@/components/overlay/detail/ReviewDetailDialog";
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
type EventViewProps = {
reviewItems?: SegmentedReviewData;
@ -461,8 +462,6 @@ function DetectionReview({
}: DetectionReviewProps) {
const reviewTimelineRef = useRef<HTMLDivElement>(null);
const segmentDuration = 60;
// detail
const [reviewDetail, setReviewDetail] = useState<ReviewSegment>();
@ -492,9 +491,38 @@ function DetectionReview({
[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 } =
useTimelineUtils({
segmentDuration,
segmentDuration: zoomSettings.segmentDuration,
timelineDuration,
timelineRef: reviewTimelineRef,
});
@ -692,7 +720,7 @@ function DetectionReview({
data-start={value.start_time}
data-segment-start={
alignStartDateToTimeline(value.start_time) -
segmentDuration
zoomSettings.segmentDuration
}
className="review-item relative rounded-lg"
>
@ -749,13 +777,13 @@ function DetectionReview({
</div>
</div>
<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 ? (
<Skeleton className="size-full" />
) : (
<EventReviewTimeline
segmentDuration={segmentDuration}
timestampSpread={15}
segmentDuration={zoomSettings.segmentDuration}
timestampSpread={zoomSettings.timestampSpread}
timelineStart={timeRange.before}
timelineEnd={timeRange.after}
showMinimap={showMinimap && !previewTime}
@ -769,6 +797,8 @@ function DetectionReview({
contentRef={contentRef}
timelineRef={reviewTimelineRef}
dense={isMobile}
isZooming={isZooming}
zoomDirection={zoomDirection}
/>
)}
</div>
@ -780,7 +810,7 @@ function DetectionReview({
reviewTimelineRef={reviewTimelineRef}
timelineStart={timeRange.before}
timelineEnd={timeRange.after}
segmentDuration={segmentDuration}
segmentDuration={zoomSettings.segmentDuration}
events={reviewItems?.all ?? []}
severityType={severity}
/>
@ -1095,7 +1125,6 @@ function MotionReview({
setHandlebarTime={setCurrentTime}
events={reviewItems?.all ?? []}
motion_events={motionData ?? []}
severityType="significant_motion"
contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => {
if (playing && scrubbing) {
@ -1105,6 +1134,8 @@ function MotionReview({
setScrubbing(scrubbing);
}}
dense={isMobileOnly}
isZooming={false}
zoomDirection={null}
/>
) : (
<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 { cn } from "@/lib/utils";
import { useFullscreen } from "@/hooks/use-fullscreen";
const SEGMENT_DURATION = 30;
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
type RecordingViewProps = {
startCamera: string;
@ -633,6 +632,7 @@ export function RecordingView({
type TimelineProps = {
contentRef: MutableRefObject<HTMLDivElement | null>;
timelineRef?: MutableRefObject<HTMLDivElement | null>;
mainCamera: string;
timelineType: TimelineType;
timeRange: TimeRange;
@ -646,6 +646,7 @@ type TimelineProps = {
};
function Timeline({
contentRef,
timelineRef,
mainCamera,
timelineType,
timeRange,
@ -657,12 +658,48 @@ function Timeline({
setScrubbing,
setExportRange,
}: 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",
{
before: timeRange.before,
after: timeRange.after,
scale: SEGMENT_DURATION / 2,
scale: Math.round(zoomSettings.segmentDuration / 2),
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 bottom-0 z-20 h-[30px] w-full bg-gradient-to-t from-secondary to-transparent"></div>
{timelineType == "timeline" ? (
motionData ? (
!isLoading ? (
<MotionReviewTimeline
segmentDuration={30}
timestampSpread={15}
timelineRef={selectedTimelineRef}
segmentDuration={zoomSettings.segmentDuration}
timestampSpread={zoomSettings.timestampSpread}
timelineStart={timeRange.before}
timelineEnd={timeRange.after}
showHandlebar={exportRange == undefined}
@ -711,9 +749,10 @@ function Timeline({
setHandlebarTime={setCurrentTime}
events={mainCameraReviewItems}
motion_events={motionData ?? []}
severityType="significant_motion"
contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
isZooming={isZooming}
zoomDirection={zoomDirection}
/>
) : (
<Skeleton className="size-full" />

View File

@ -32,6 +32,8 @@ module.exports = {
scale2: "scale2 3s ease-in-out infinite",
scale3: "scale3 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: {
wide: "32 / 9",
@ -140,6 +142,16 @@ module.exports = {
"40%, 60%": { transform: "scale(1.4)" },
"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: {
xs: "480px",