Summary timeline (#10569)

* implement summary timeline

* implement summary timeline

* merge dev

* conditionally attach listeners only when dragging

* set up listeners with a ref
This commit is contained in:
Josh Hawkins 2024-03-20 21:56:15 -05:00 committed by GitHub
parent c8fd23caa1
commit f113acee33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 523 additions and 84 deletions

View File

@ -43,7 +43,7 @@ export default function NewReviewData({
return ( return (
<div className={className}> <div className={className}>
<div className="flex justify-center items-center md:mr-[100px]"> <div className="flex justify-center items-center md:mr-[115px] pointer-events-auto">
<Button <Button
className={`${ className={`${
hasUpdate hasUpdate

View File

@ -30,6 +30,7 @@ export type EventReviewTimelineProps = {
setExportEndTime?: React.Dispatch<React.SetStateAction<number>>; setExportEndTime?: React.Dispatch<React.SetStateAction<number>>;
events: ReviewSegment[]; events: ReviewSegment[];
severityType: ReviewSeverity; severityType: ReviewSeverity;
timelineRef?: RefObject<HTMLDivElement>;
contentRef: RefObject<HTMLDivElement>; contentRef: RefObject<HTMLDivElement>;
onHandlebarDraggingChange?: (isDragging: boolean) => void; onHandlebarDraggingChange?: (isDragging: boolean) => void;
}; };
@ -52,6 +53,7 @@ export function EventReviewTimeline({
setExportEndTime, setExportEndTime,
events, events,
severityType, severityType,
timelineRef,
contentRef, contentRef,
onHandlebarDraggingChange, onHandlebarDraggingChange,
}: EventReviewTimelineProps) { }: EventReviewTimelineProps) {
@ -59,7 +61,7 @@ export function EventReviewTimeline({
const [exportStartPosition, setExportStartPosition] = useState(0); const [exportStartPosition, setExportStartPosition] = useState(0);
const [exportEndPosition, setExportEndPosition] = useState(0); const [exportEndPosition, setExportEndPosition] = useState(0);
const timelineRef = useRef<HTMLDivElement>(null); const internalTimelineRef = useRef<HTMLDivElement>(null);
const handlebarRef = useRef<HTMLDivElement>(null); const handlebarRef = useRef<HTMLDivElement>(null);
const handlebarTimeRef = useRef<HTMLDivElement>(null); const handlebarTimeRef = useRef<HTMLDivElement>(null);
const exportStartRef = useRef<HTMLDivElement>(null); const exportStartRef = useRef<HTMLDivElement>(null);
@ -98,7 +100,7 @@ export function EventReviewTimeline({
handleMouseMove: handlebarMouseMove, handleMouseMove: handlebarMouseMove,
} = useDraggableElement({ } = useDraggableElement({
contentRef, contentRef,
timelineRef, timelineRef: timelineRef || internalTimelineRef,
draggableElementRef: handlebarRef, draggableElementRef: handlebarRef,
segmentDuration, segmentDuration,
showDraggableElement: showHandlebar, showDraggableElement: showHandlebar,
@ -117,7 +119,7 @@ export function EventReviewTimeline({
handleMouseMove: exportStartMouseMove, handleMouseMove: exportStartMouseMove,
} = useDraggableElement({ } = useDraggableElement({
contentRef, contentRef,
timelineRef, timelineRef: timelineRef || internalTimelineRef,
draggableElementRef: exportStartRef, draggableElementRef: exportStartRef,
segmentDuration, segmentDuration,
showDraggableElement: showExportHandles, showDraggableElement: showExportHandles,
@ -138,7 +140,7 @@ export function EventReviewTimeline({
handleMouseMove: exportEndMouseMove, handleMouseMove: exportEndMouseMove,
} = useDraggableElement({ } = useDraggableElement({
contentRef, contentRef,
timelineRef, timelineRef: timelineRef || internalTimelineRef,
draggableElementRef: exportEndRef, draggableElementRef: exportEndRef,
segmentDuration, segmentDuration,
showDraggableElement: showExportHandles, showDraggableElement: showExportHandles,
@ -213,7 +215,7 @@ export function EventReviewTimeline({
return ( return (
<ReviewTimeline <ReviewTimeline
timelineRef={timelineRef} timelineRef={timelineRef || internalTimelineRef}
handlebarRef={handlebarRef} handlebarRef={handlebarRef}
handlebarTimeRef={handlebarTimeRef} handlebarTimeRef={handlebarTimeRef}
handlebarMouseMove={handlebarMouseMove} handlebarMouseMove={handlebarMouseMove}

View File

@ -68,12 +68,7 @@ export function EventSegment({
[getReviewed, segmentTime], [getReviewed, segmentTime],
); );
const { const { roundTopPrimary, roundBottomPrimary } = useMemo(
roundTopPrimary,
roundBottomPrimary,
roundTopSecondary,
roundBottomSecondary,
} = useMemo(
() => shouldShowRoundedCorners(segmentTime), () => shouldShowRoundedCorners(segmentTime),
[shouldShowRoundedCorners, segmentTime], [shouldShowRoundedCorners, segmentTime],
); );
@ -253,20 +248,6 @@ export function EventSegment({
</div> </div>
</HoverCard> </HoverCard>
)} )}
{severityValue !== displaySeverityType && (
<div className="absolute right-0 h-2 z-10">
<div
key={`${segmentKey}_${index}_secondary_data`}
className={`
w-1 h-2 bg-gradient-to-r
${roundBottomSecondary ? "rounded-bl-full rounded-br-full" : ""}
${roundTopSecondary ? "rounded-tl-full rounded-tr-full" : ""}
${severityColors[severityValue]}
`}
></div>
</div>
)}
</React.Fragment> </React.Fragment>
))} ))}
</div> </div>

View File

@ -32,6 +32,7 @@ export type MotionReviewTimelineProps = {
motion_events: MotionData[]; motion_events: MotionData[];
severityType: ReviewSeverity; severityType: ReviewSeverity;
contentRef: RefObject<HTMLDivElement>; contentRef: RefObject<HTMLDivElement>;
timelineRef?: RefObject<HTMLDivElement>;
onHandlebarDraggingChange?: (isDragging: boolean) => void; onHandlebarDraggingChange?: (isDragging: boolean) => void;
}; };
@ -54,13 +55,14 @@ export function MotionReviewTimeline({
events, events,
motion_events, motion_events,
contentRef, contentRef,
timelineRef,
onHandlebarDraggingChange, onHandlebarDraggingChange,
}: MotionReviewTimelineProps) { }: MotionReviewTimelineProps) {
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [exportStartPosition, setExportStartPosition] = useState(0); const [exportStartPosition, setExportStartPosition] = useState(0);
const [exportEndPosition, setExportEndPosition] = useState(0); const [exportEndPosition, setExportEndPosition] = useState(0);
const timelineRef = useRef<HTMLDivElement>(null); const internalTimelineRef = useRef<HTMLDivElement>(null);
const handlebarRef = useRef<HTMLDivElement>(null); const handlebarRef = useRef<HTMLDivElement>(null);
const handlebarTimeRef = useRef<HTMLDivElement>(null); const handlebarTimeRef = useRef<HTMLDivElement>(null);
const exportStartRef = useRef<HTMLDivElement>(null); const exportStartRef = useRef<HTMLDivElement>(null);
@ -99,7 +101,7 @@ export function MotionReviewTimeline({
handleMouseMove: handlebarMouseMove, handleMouseMove: handlebarMouseMove,
} = useDraggableElement({ } = useDraggableElement({
contentRef, contentRef,
timelineRef, timelineRef: timelineRef || internalTimelineRef,
draggableElementRef: handlebarRef, draggableElementRef: handlebarRef,
segmentDuration, segmentDuration,
showDraggableElement: showHandlebar, showDraggableElement: showHandlebar,
@ -118,7 +120,7 @@ export function MotionReviewTimeline({
handleMouseMove: exportStartMouseMove, handleMouseMove: exportStartMouseMove,
} = useDraggableElement({ } = useDraggableElement({
contentRef, contentRef,
timelineRef, timelineRef: timelineRef || internalTimelineRef,
draggableElementRef: exportStartRef, draggableElementRef: exportStartRef,
segmentDuration, segmentDuration,
showDraggableElement: showExportHandles, showDraggableElement: showExportHandles,
@ -139,7 +141,7 @@ export function MotionReviewTimeline({
handleMouseMove: exportEndMouseMove, handleMouseMove: exportEndMouseMove,
} = useDraggableElement({ } = useDraggableElement({
contentRef, contentRef,
timelineRef, timelineRef: timelineRef || internalTimelineRef,
draggableElementRef: exportEndRef, draggableElementRef: exportEndRef,
segmentDuration, segmentDuration,
showDraggableElement: showExportHandles, showDraggableElement: showExportHandles,
@ -213,9 +215,46 @@ export function MotionReviewTimeline({
} }
}, [isDragging, onHandlebarDraggingChange]); }, [isDragging, onHandlebarDraggingChange]);
const segmentsObserver = useRef<IntersectionObserver | null>(null);
const selectedTimelineRef = timelineRef || internalTimelineRef;
useEffect(() => {
if (selectedTimelineRef.current && segments) {
segmentsObserver.current = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
const segmentId = entry.target.getAttribute("data-segment-id");
const segmentElements =
internalTimelineRef.current?.querySelectorAll(
`[data-segment-id="${segmentId}"] .motion-segment`,
);
segmentElements?.forEach((segmentElement) => {
segmentElement.classList.remove("hidden");
segmentElement.classList.add("animate-in");
});
}
});
},
{ threshold: 0 },
);
// Get all segment divs and observe each one
const segmentDivs =
selectedTimelineRef.current.querySelectorAll(".segment.has-data");
segmentDivs.forEach((segmentDiv) => {
segmentsObserver.current?.observe(segmentDiv);
});
}
return () => {
segmentsObserver.current?.disconnect();
};
}, [selectedTimelineRef, segments]);
return ( return (
<ReviewTimeline <ReviewTimeline
timelineRef={timelineRef} timelineRef={timelineRef || internalTimelineRef}
handlebarRef={handlebarRef} handlebarRef={handlebarRef}
handlebarTimeRef={handlebarTimeRef} handlebarTimeRef={handlebarTimeRef}
handlebarMouseMove={handlebarMouseMove} handlebarMouseMove={handlebarMouseMove}

View File

@ -176,7 +176,8 @@ export function MotionSegment({
return ( return (
<div <div
key={segmentKey} key={segmentKey}
className={segmentClasses} data-segment-id={segmentKey}
className={`segment ${firstHalfSegmentWidth > 1 || secondHalfSegmentWidth > 1 ? "has-data" : ""} ${segmentClasses}`}
onClick={segmentClick} onClick={segmentClick}
onTouchEnd={(event) => handleTouchStart(event, segmentClick)} onTouchEnd={(event) => handleTouchStart(event, segmentClick)}
> >
@ -203,7 +204,7 @@ export function MotionSegment({
<div className="flex justify-center"> <div className="flex justify-center">
<div <div
key={`${segmentKey}_motion_data_1`} key={`${segmentKey}_motion_data_1`}
className={`h-[2px] rounded-full ${severity[0] != 0 ? "bg-motion_review-dimmed" : "bg-motion_review"}`} className={`motion-segment ${secondHalfSegmentWidth > 1 ? "hidden" : ""} zoom-in-[0.2] ${secondHalfSegmentWidth < 5 ? "duration-200" : "duration-1000"} h-[2px] rounded-full ${severity[0] != 0 ? "bg-motion_review-dimmed" : "bg-motion_review"}`}
style={{ style={{
width: secondHalfSegmentWidth, width: secondHalfSegmentWidth,
}} }}
@ -215,7 +216,7 @@ export function MotionSegment({
<div className="flex justify-center"> <div className="flex justify-center">
<div <div
key={`${segmentKey}_motion_data_2`} key={`${segmentKey}_motion_data_2`}
className={`h-[2px] rounded-full ${severity[0] != 0 ? "bg-motion_review-dimmed" : "bg-motion_review"}`} className={`motion-segment ${firstHalfSegmentWidth > 1 ? "hidden" : ""} zoom-in-[0.2] ${firstHalfSegmentWidth < 5 ? "duration-200" : "duration-1000"} h-[2px] rounded-full ${severity[0] != 0 ? "bg-motion_review-dimmed" : "bg-motion_review"}`}
style={{ style={{
width: firstHalfSegmentWidth, width: firstHalfSegmentWidth,
}} }}

View File

@ -234,7 +234,7 @@ export function ReviewTimeline({
onTouchMove={handleMouseMove} onTouchMove={handleMouseMove}
onMouseUp={handleMouseUp} onMouseUp={handleMouseUp}
onTouchEnd={handleMouseUp} onTouchEnd={handleMouseUp}
className={`relative h-full overflow-y-auto no-scrollbar bg-secondary ${ className={`relative h-full overflow-y-auto no-scrollbar select-none bg-secondary ${
isDragging && (showHandlebar || showExportHandles) isDragging && (showHandlebar || showExportHandles)
? "cursor-grabbing" ? "cursor-grabbing"
: "cursor-auto" : "cursor-auto"

View File

@ -0,0 +1,67 @@
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils";
import { ReviewSegment } from "@/types/review";
import React, { useMemo } from "react";
// import useTapUtils from "@/hooks/use-tap-utils";
type SummarySegmentProps = {
events: ReviewSegment[];
segmentTime: number;
segmentDuration: number;
segmentHeight: number;
};
export function SummarySegment({
events,
segmentTime,
segmentDuration,
segmentHeight,
}: SummarySegmentProps) {
const severityType = "all";
const { getSeverity, getReviewed, displaySeverityType } =
useEventSegmentUtils(segmentDuration, events, severityType);
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 ? "bg-severity_motion/50" : "bg-severity_motion",
2: reviewed ? "bg-severity_detection/50" : "bg-severity_detection",
3: reviewed ? "bg-severity_alert/50" : "bg-severity_alert",
};
return (
<div
key={segmentKey}
className="relative w-full"
style={{ height: segmentHeight }}
>
{severity.map((severityValue: number, index: number) => {
return (
<React.Fragment key={index}>
<div
className="flex justify-end cursor-pointer"
style={{ height: segmentHeight }}
>
<div
key={`${segmentKey}_${index}_secondary_data`}
style={{ height: segmentHeight }}
className={`w-[10px] ${severityColors[severityValue]}`}
></div>
</div>
</React.Fragment>
);
})}
</div>
);
}
export default SummarySegment;

View File

@ -0,0 +1,317 @@
import {
RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { SummarySegment } from "./SummarySegment";
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { ReviewSegment } from "@/types/review";
import { isMobile } from "react-device-detect";
export type SummaryTimelineProps = {
reviewTimelineRef: RefObject<HTMLDivElement>;
timelineStart: number;
timelineEnd: number;
segmentDuration: number;
events: ReviewSegment[];
};
export function SummaryTimeline({
reviewTimelineRef,
timelineStart,
timelineEnd,
segmentDuration,
events,
}: 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);
const [initialReviewTimelineScrollTop, setInitialReviewTimelineScrollTop] =
useState<number>(0);
const { alignStartDateToTimeline } = useTimelineUtils(segmentDuration);
const timelineStartAligned = useMemo(
() => alignStartDateToTimeline(timelineStart) + 2 * segmentDuration,
[timelineStart, alignStartDateToTimeline, segmentDuration],
);
const reviewTimelineDuration = useMemo(
() => timelineStart - timelineEnd + 4 * segmentDuration,
[timelineEnd, timelineStart, segmentDuration],
);
// Generate segments for the timeline
const generateSegments = useCallback(() => {
const segmentCount = reviewTimelineDuration / segmentDuration;
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}
/>
);
});
}
}, [
segmentDuration,
timelineStartAligned,
events,
reviewTimelineDuration,
segmentHeight,
]);
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,
],
);
useEffect(() => {
if (reviewTimelineRef.current && summaryTimelineRef.current) {
const content = reviewTimelineRef.current;
const summary = summaryTimelineRef.current;
const handleScroll = () => {
const {
clientHeight: reviewTimelineVisibleHeight,
scrollHeight: reviewTimelineFullHeight,
scrollTop: scrolled,
} = content;
const { clientHeight: summaryTimelineVisibleHeight } = summary;
if (visibleSectionRef.current) {
visibleSectionRef.current.style.top = `${summaryTimelineVisibleHeight * (scrolled / reviewTimelineFullHeight)}px`;
visibleSectionRef.current.style.height = `${reviewTimelineVisibleHeight * (reviewTimelineVisibleHeight / reviewTimelineFullHeight)}px`;
}
};
content.addEventListener("scroll", handleScroll);
return () => {
content.removeEventListener("scroll", handleScroll);
};
}
}, [reviewTimelineRef, summaryTimelineRef]);
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>,
) => {
// prevent default only for mouse events
// to avoid chrome/android issues
if (e.nativeEvent instanceof MouseEvent) {
e.preventDefault();
}
e.stopPropagation();
let clientY;
if (isMobile && e.nativeEvent instanceof TouchEvent) {
clientY = e.nativeEvent.touches[0].clientY;
} else if (e.nativeEvent instanceof MouseEvent) {
clientY = e.nativeEvent.clientY;
}
if (
clientY &&
reviewTimelineRef.current &&
summaryTimelineRef.current &&
visibleSectionRef.current
) {
const { clientHeight: summaryTimelineVisibleHeight } =
summaryTimelineRef.current;
const rect = summaryTimelineRef.current.getBoundingClientRect();
const summaryTimelineTop = rect.top;
const { scrollHeight: reviewTimelineHeight } =
reviewTimelineRef.current;
const { clientHeight: visibleSectionHeight } =
visibleSectionRef.current;
const visibleSectionOffset = -(visibleSectionHeight / 2);
const clickPercentage =
(clientY - summaryTimelineTop + visibleSectionOffset) /
summaryTimelineVisibleHeight;
reviewTimelineRef.current.scrollTo({
top: Math.floor(reviewTimelineHeight * clickPercentage),
behavior: "smooth",
});
}
},
[reviewTimelineRef, summaryTimelineRef, visibleSectionRef],
);
const handleMouseDown = useCallback(
(
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,
) => {
// prevent default only for mouse events
// to avoid chrome/android issues
if (e.nativeEvent instanceof MouseEvent) {
e.preventDefault();
}
e.stopPropagation();
setIsDragging(true);
let clientY;
if (isMobile && e.nativeEvent instanceof TouchEvent) {
clientY = e.nativeEvent.touches[0].clientY;
} else if (e.nativeEvent instanceof MouseEvent) {
clientY = e.nativeEvent.clientY;
}
if (clientY && summaryTimelineRef.current && reviewTimelineRef.current) {
setScrollStartPosition(clientY);
setInitialReviewTimelineScrollTop(reviewTimelineRef.current.scrollTop);
}
},
[setIsDragging, summaryTimelineRef, reviewTimelineRef],
);
const handleMouseUp = useCallback(
(e: MouseEvent | TouchEvent) => {
e.preventDefault();
e.stopPropagation();
if (isDragging) {
setIsDragging(false);
}
},
[isDragging, setIsDragging],
);
const handleMouseMove = useCallback(
(e: MouseEvent | TouchEvent) => {
if (
summaryTimelineRef.current &&
reviewTimelineRef.current &&
visibleSectionRef.current
) {
// prevent default only for mouse events
// to avoid chrome/android issues
if (e instanceof MouseEvent) {
e.preventDefault();
}
e.stopPropagation();
let clientY;
if (isMobile && e instanceof TouchEvent) {
clientY = e.touches[0].clientY;
} else if (e instanceof MouseEvent) {
clientY = e.clientY;
}
if (isDragging && clientY) {
const { clientHeight: summaryTimelineVisibleHeight } =
summaryTimelineRef.current;
const {
scrollHeight: reviewTimelineHeight,
clientHeight: reviewTimelineVisibleHeight,
} = reviewTimelineRef.current;
const { clientHeight: visibleSectionHeight } =
visibleSectionRef.current;
const deltaY =
(clientY - scrollStartPosition) *
(summaryTimelineVisibleHeight / visibleSectionHeight);
const newScrollTop = Math.min(
initialReviewTimelineScrollTop + deltaY,
reviewTimelineHeight - reviewTimelineVisibleHeight,
);
reviewTimelineRef.current.scrollTop = newScrollTop;
}
}
},
[
initialReviewTimelineScrollTop,
isDragging,
reviewTimelineRef,
scrollStartPosition,
],
);
const documentRef = useRef<Document | null>(document);
useEffect(() => {
const documentInstance = documentRef.current;
if (isDragging) {
documentInstance?.addEventListener("mousemove", handleMouseMove);
documentInstance?.addEventListener("touchmove", handleMouseMove);
documentInstance?.addEventListener("mouseup", handleMouseUp);
documentInstance?.addEventListener("touchend", handleMouseUp);
} else {
documentInstance?.removeEventListener("mousemove", handleMouseMove);
documentInstance?.removeEventListener("touchmove", handleMouseMove);
documentInstance?.removeEventListener("mouseup", handleMouseUp);
documentInstance?.removeEventListener("touchend", handleMouseUp);
}
return () => {
documentInstance?.removeEventListener("mousemove", handleMouseMove);
documentInstance?.removeEventListener("touchmove", handleMouseMove);
documentInstance?.removeEventListener("mouseup", handleMouseUp);
documentInstance?.removeEventListener("touchend", handleMouseUp);
};
}, [handleMouseMove, handleMouseUp, isDragging]);
return (
<div
className={`relative h-full overflow-hidden no-scrollbar select-none bg-secondary border-l-[1px] border-neutral-700`}
role="scrollbar"
>
<div
ref={summaryTimelineRef}
className="h-full flex flex-col relative z-10"
onClick={timelineClick}
onTouchEnd={timelineClick}
>
{segments}
</div>
<div
ref={visibleSectionRef}
onMouseDown={handleMouseDown}
onTouchStart={handleMouseDown}
className={`bg-primary-foreground/30 z-20 absolute w-full touch-none ${
isDragging ? "cursor-grabbing" : "cursor-grab"
}`}
></div>
</div>
);
}
export default SummaryTimeline;

View File

@ -32,7 +32,7 @@ export function MinimapBounds({
<> <>
{isFirstSegmentInMinimap && ( {isFirstSegmentInMinimap && (
<div <div
className="absolute inset-0 -bottom-7 w-full flex items-center justify-center text-primary-foreground font-medium z-20 text-center text-[10px] scroll-mt-8 pointer-events-none" className="absolute inset-0 -bottom-7 w-full flex items-center justify-center text-primary-foreground font-medium z-20 text-center text-[10px] scroll-mt-8 pointer-events-none select-none"
ref={firstMinimapSegmentRef} ref={firstMinimapSegmentRef}
> >
{new Date(alignedMinimapStartTime * 1000).toLocaleTimeString([], { {new Date(alignedMinimapStartTime * 1000).toLocaleTimeString([], {
@ -44,7 +44,7 @@ export function MinimapBounds({
)} )}
{isLastSegmentInMinimap && ( {isLastSegmentInMinimap && (
<div className="absolute inset-0 -top-3 w-full flex items-center justify-center text-primary-foreground font-medium z-20 text-center text-[10px] pointer-events-none"> <div className="absolute inset-0 -top-3 w-full flex items-center justify-center text-primary-foreground font-medium z-20 text-center text-[10px] pointer-events-none select-none">
{new Date(alignedMinimapEndTime * 1000).toLocaleTimeString([], { {new Date(alignedMinimapEndTime * 1000).toLocaleTimeString([], {
hour: "2-digit", hour: "2-digit",
minute: "2-digit", minute: "2-digit",
@ -61,7 +61,7 @@ export function Tick({ timestamp, timestampSpread }: TickSegmentProps) {
<div className="absolute"> <div className="absolute">
<div className="flex items-end content-end w-[12px] h-2"> <div className="flex items-end content-end w-[12px] h-2">
<div <div
className={`pointer-events-none h-0.5 ${ className={`pointer-events-none select-none h-0.5 ${
timestamp.getMinutes() % timestampSpread === 0 && timestamp.getMinutes() % timestampSpread === 0 &&
timestamp.getSeconds() === 0 timestamp.getSeconds() === 0
? "w-[12px] bg-neutral-600 dark:bg-neutral-500" ? "w-[12px] bg-neutral-600 dark:bg-neutral-500"
@ -88,7 +88,7 @@ export function Timestamp({
{!isFirstSegmentInMinimap && !isLastSegmentInMinimap && ( {!isFirstSegmentInMinimap && !isLastSegmentInMinimap && (
<div <div
key={`${segmentKey}_timestamp`} key={`${segmentKey}_timestamp`}
className="pointer-events-none text-[8px] text-neutral-600 dark:text-neutral-500" className="pointer-events-none select-none text-[8px] text-neutral-600 dark:text-neutral-500"
> >
{timestamp.getMinutes() % timestampSpread === 0 && {timestamp.getMinutes() % timestampSpread === 0 &&
timestamp.getSeconds() === 0 && timestamp.getSeconds() === 0 &&

View File

@ -40,7 +40,8 @@ function useDraggableElement({
}: DraggableElementProps) { }: DraggableElementProps) {
const [clientYPosition, setClientYPosition] = useState<number | null>(null); const [clientYPosition, setClientYPosition] = useState<number | null>(null);
const [initialClickAdjustment, setInitialClickAdjustment] = useState(0); const [initialClickAdjustment, setInitialClickAdjustment] = useState(0);
const { alignStartDateToTimeline } = useTimelineUtils(segmentDuration); const { alignStartDateToTimeline, getCumulativeScrollTop } =
useTimelineUtils(segmentDuration);
const draggingAtTopEdge = useMemo(() => { const draggingAtTopEdge = useMemo(() => {
if (clientYPosition && timelineRef.current) { if (clientYPosition && timelineRef.current) {
@ -125,15 +126,6 @@ function useDraggableElement({
[isDragging, setIsDragging], [isDragging, setIsDragging],
); );
const getCumulativeScrollTop = useCallback((element: HTMLElement | null) => {
let scrollTop = 0;
while (element) {
scrollTop += element.scrollTop;
element = element.parentElement;
}
return scrollTop;
}, []);
const timestampToPixels = useCallback( const timestampToPixels = useCallback(
(time: number) => { (time: number) => {
const { scrollHeight: timelineHeight } = const { scrollHeight: timelineHeight } =

View File

@ -58,11 +58,12 @@ export const useEventSegmentUtils = (
); );
const highestSeverityValue = Math.max(...severityValues); const highestSeverityValue = Math.max(...severityValues);
if ( if (severityValues.includes(displaySeverityType)) {
severityValues.includes(displaySeverityType) && const otherSeverityValues = severityValues.filter(
displaySeverityType !== highestSeverityValue (severity) => severity !== displaySeverityType,
) { );
return [displaySeverityType, highestSeverityValue]; const highestOtherSeverityValue = Math.max(...otherSeverityValues);
return [displaySeverityType, highestOtherSeverityValue];
} else { } else {
return [highestSeverityValue]; return [highestSeverityValue];
} }

View File

@ -19,8 +19,18 @@ export const useTimelineUtils = (segmentDuration: number) => {
[segmentDuration], [segmentDuration],
); );
const getCumulativeScrollTop = useCallback((element: HTMLElement | null) => {
let scrollTop = 0;
while (element) {
scrollTop += element.scrollTop;
element = element.parentElement;
}
return scrollTop;
}, []);
return { return {
alignEndDateToTimeline, alignEndDateToTimeline,
alignStartDateToTimeline, alignStartDateToTimeline,
getCumulativeScrollTop,
}; };
}; };

View File

@ -26,6 +26,7 @@ import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
import SummaryTimeline from "@/components/timeline/SummaryTimeline";
// Color data // Color data
const colors = [ const colors = [
@ -125,6 +126,7 @@ const generateRandomEvent = (): ReviewSegment => {
function UIPlayground() { function UIPlayground() {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const contentRef = useRef<HTMLDivElement>(null); const contentRef = useRef<HTMLDivElement>(null);
const reviewTimelineRef = useRef<HTMLDivElement>(null);
const [mockEvents, setMockEvents] = useState<ReviewSegment[]>([]); const [mockEvents, setMockEvents] = useState<ReviewSegment[]>([]);
const [mockMotionData, setMockMotionData] = useState<MotionData[]>([]); const [mockMotionData, setMockMotionData] = useState<MotionData[]>([]);
const [handlebarTime, setHandlebarTime] = useState( const [handlebarTime, setHandlebarTime] = useState(
@ -144,7 +146,7 @@ function UIPlayground() {
const navigate = useNavigate(); const navigate = useNavigate();
useMemo(() => { useMemo(() => {
const initialEvents = Array.from({ length: 50 }, generateRandomEvent); const initialEvents = Array.from({ length: 10 }, generateRandomEvent);
setMockEvents(initialEvents); setMockEvents(initialEvents);
setMockMotionData(generateRandomMotionAudioData()); setMockMotionData(generateRandomMotionAudioData());
}, []); }, []);
@ -403,9 +405,21 @@ function UIPlayground() {
events={mockEvents} // events, including new has_been_reviewed and severity properties events={mockEvents} // events, including new has_been_reviewed and severity properties
severityType={"alert"} // choose the severity type for the middle line - all other severity types are to the right 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
timelineRef={reviewTimelineRef}
/> />
)} )}
</div> </div>
{isEventsReviewTimeline && (
<div className="w-[10px]">
<SummaryTimeline
reviewTimelineRef={reviewTimelineRef}
timelineStart={Math.floor(Date.now() / 1000)} // timestamp start of the timeline - the earlier time
timelineEnd={Math.floor(Date.now() / 1000) - 6 * 60 * 60} // end of timeline - the later time
segmentDuration={zoomSettings.segmentDuration}
events={mockEvents}
/>
</div>
)}
</div> </div>
</div> </div>
</> </>

View File

@ -36,6 +36,7 @@ import { Button } from "@/components/ui/button";
import PreviewPlayer, { import PreviewPlayer, {
PreviewController, PreviewController,
} from "@/components/player/PreviewPlayer"; } from "@/components/player/PreviewPlayer";
import SummaryTimeline from "@/components/timeline/SummaryTimeline";
import { RecordingStartingPoint } from "@/types/record"; import { RecordingStartingPoint } from "@/types/record";
type EventViewProps = { type EventViewProps = {
@ -329,6 +330,8 @@ function DetectionReview({
onSelectReview, onSelectReview,
pullLatestData, pullLatestData,
}: DetectionReviewProps) { }: DetectionReviewProps) {
const reviewTimelineRef = useRef<HTMLDivElement>(null);
const segmentDuration = 60; const segmentDuration = 60;
// review data // review data
@ -458,7 +461,7 @@ function DetectionReview({
> >
{filter?.before == undefined && ( {filter?.before == undefined && (
<NewReviewData <NewReviewData
className="absolute w-full z-30" className="absolute w-full z-30 pointer-events-none"
contentRef={contentRef} contentRef={contentRef}
severity={severity} severity={severity}
hasUpdate={hasUpdate} hasUpdate={hasUpdate}
@ -527,21 +530,33 @@ function DetectionReview({
)} )}
</div> </div>
</div> </div>
<div className="w-[55px] md:w-[100px] mt-2 overflow-y-auto no-scrollbar"> <div className="w-[65px] md:w-[110px] mt-2 flex flex-row">
<EventReviewTimeline <div className="w-[55px] md:w-[100px] overflow-y-auto no-scrollbar">
segmentDuration={segmentDuration} <EventReviewTimeline
timestampSpread={15} segmentDuration={segmentDuration}
timelineStart={timeRange.before} timestampSpread={15}
timelineEnd={timeRange.after} timelineStart={timeRange.before}
showMinimap={showMinimap && !previewTime} timelineEnd={timeRange.after}
minimapStartTime={minimapBounds.start} showMinimap={showMinimap && !previewTime}
minimapEndTime={minimapBounds.end} minimapStartTime={minimapBounds.start}
showHandlebar={previewTime != undefined} minimapEndTime={minimapBounds.end}
handlebarTime={previewTime} showHandlebar={previewTime != undefined}
events={reviewItems?.all ?? []} handlebarTime={previewTime}
severityType={severity} events={reviewItems?.all ?? []}
contentRef={contentRef} severityType={severity}
/> contentRef={contentRef}
timelineRef={reviewTimelineRef}
/>
</div>
<div className="w-[10px]">
<SummaryTimeline
reviewTimelineRef={reviewTimelineRef}
timelineStart={timeRange.before}
timelineEnd={timeRange.after}
segmentDuration={segmentDuration}
events={reviewItems?.all ?? []}
/>
</div>
</div> </div>
</> </>
); );

View File

@ -22,16 +22,16 @@
--primary: 0 0% 100%; --primary: 0 0% 100%;
--primary-foreground: hsl(0, 0%, 0%); --primary-foreground: hsl(0, 0%, 0%);
--primary-foreground: 0, 0%, 0%; --primary-foreground: 0 0% 0%;
--secondary: hsl(0, 0%, 96%); --secondary: hsl(0, 0%, 96%);
--secondary: 0, 0%, 96%; --secondary: 0 0% 96%;
--secondary-foreground: hsl(0, 0%, 45%); --secondary-foreground: hsl(0, 0%, 45%);
--secondary-foreground: 0, 0%, 45%; --secondary-foreground: 0 0% 45%;
--secondary-highlight: hsl(0, 0%, 94%); --secondary-highlight: hsl(0, 0%, 94%);
--secondary-highlight: 0, 0%, 94%; --secondary-highlight: 0 0% 94%;
--muted: hsl(210 40% 96.1%); --muted: hsl(210 40% 96.1%);
--muted: 210 40% 96.1%; --muted: 210 40% 96.1%;
@ -104,28 +104,28 @@
--popover-foreground: 210 40% 98%; --popover-foreground: 210 40% 98%;
--primary: hsl(0, 0%, 9%); --primary: hsl(0, 0%, 9%);
--primary: 0, 0%, 9%; --primary: 0 0% 9%;
--primary-foreground: hsl(0, 0%, 100%); --primary-foreground: hsl(0, 0%, 100%);
--primary-foreground: 0, 0%, 100%; --primary-foreground: 0 0% 100%;
--secondary: hsl(0, 0%, 15%); --secondary: hsl(0, 0%, 15%);
--secondary: 0, 0%, 15%; --secondary: 0 0% 15%;
--secondary-foreground: hsl(0, 0%, 83%); --secondary-foreground: hsl(0, 0%, 83%);
--secondary-foreground: 0, 0%, 83%; --secondary-foreground: 0 0% 83%;
--secondary-highlight: hsl(0, 0%, 25%); --secondary-highlight: hsl(0, 0%, 25%);
--secondary-highlight: 0, 0%, 25%; --secondary-highlight: 0 0% 25%;
--muted: hsl(0, 0%, 8%); --muted: hsl(0, 0%, 8%);
--muted: 0, 0%, 8%; --muted: 0 0% 8%;
--muted-foreground: hsl(0, 0%, 32%); --muted-foreground: hsl(0, 0%, 32%);
--muted-foreground: 0, 0%, 32%; --muted-foreground: 0 0% 32%;
--accent: hsl(0, 0%, 15%); --accent: hsl(0, 0%, 15%);
--accent: 0, 0%, 15%; --accent: 0 0% 15%;
--accent-foreground: hsl(210 40% 98%); --accent-foreground: hsl(210 40% 98%);
--accent-foreground: 210 40% 98%; --accent-foreground: 210 40% 98%;
@ -137,7 +137,7 @@
--destructive-foreground: 210 40% 98%; --destructive-foreground: 210 40% 98%;
--border: hsl(0, 0%, 32%); --border: hsl(0, 0%, 32%);
--border: 0, 0%, 32%; --border: 0 0% 32%;
--input: hsl(217.2 32.6% 17.5%); --input: hsl(217.2 32.6% 17.5%);
--input: 217.2 32.6% 17.5%; --input: 217.2 32.6% 17.5%;