diff --git a/web/src/components/dynamic/NewReviewData.tsx b/web/src/components/dynamic/NewReviewData.tsx index 8a4752470..c60a08b49 100644 --- a/web/src/components/dynamic/NewReviewData.tsx +++ b/web/src/components/dynamic/NewReviewData.tsx @@ -43,7 +43,7 @@ export default function NewReviewData({ return (
-
+
)} - - {severityValue !== displaySeverityType && ( -
-
-
- )} ))}
diff --git a/web/src/components/timeline/MotionReviewTimeline.tsx b/web/src/components/timeline/MotionReviewTimeline.tsx index 9a1a7b428..1b2a23f0f 100644 --- a/web/src/components/timeline/MotionReviewTimeline.tsx +++ b/web/src/components/timeline/MotionReviewTimeline.tsx @@ -32,6 +32,7 @@ export type MotionReviewTimelineProps = { motion_events: MotionData[]; severityType: ReviewSeverity; contentRef: RefObject; + timelineRef?: RefObject; onHandlebarDraggingChange?: (isDragging: boolean) => void; }; @@ -54,13 +55,14 @@ export function MotionReviewTimeline({ events, motion_events, contentRef, + timelineRef, onHandlebarDraggingChange, }: MotionReviewTimelineProps) { const [isDragging, setIsDragging] = useState(false); const [exportStartPosition, setExportStartPosition] = useState(0); const [exportEndPosition, setExportEndPosition] = useState(0); - const timelineRef = useRef(null); + const internalTimelineRef = useRef(null); const handlebarRef = useRef(null); const handlebarTimeRef = useRef(null); const exportStartRef = useRef(null); @@ -99,7 +101,7 @@ export function MotionReviewTimeline({ handleMouseMove: handlebarMouseMove, } = useDraggableElement({ contentRef, - timelineRef, + timelineRef: timelineRef || internalTimelineRef, draggableElementRef: handlebarRef, segmentDuration, showDraggableElement: showHandlebar, @@ -118,7 +120,7 @@ export function MotionReviewTimeline({ handleMouseMove: exportStartMouseMove, } = useDraggableElement({ contentRef, - timelineRef, + timelineRef: timelineRef || internalTimelineRef, draggableElementRef: exportStartRef, segmentDuration, showDraggableElement: showExportHandles, @@ -139,7 +141,7 @@ export function MotionReviewTimeline({ handleMouseMove: exportEndMouseMove, } = useDraggableElement({ contentRef, - timelineRef, + timelineRef: timelineRef || internalTimelineRef, draggableElementRef: exportEndRef, segmentDuration, showDraggableElement: showExportHandles, @@ -213,9 +215,46 @@ export function MotionReviewTimeline({ } }, [isDragging, onHandlebarDraggingChange]); + const segmentsObserver = useRef(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 ( 1 || secondHalfSegmentWidth > 1 ? "has-data" : ""} ${segmentClasses}`} onClick={segmentClick} onTouchEnd={(event) => handleTouchStart(event, segmentClick)} > @@ -203,7 +204,7 @@ export function MotionSegment({
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={{ width: secondHalfSegmentWidth, }} @@ -215,7 +216,7 @@ export function MotionSegment({
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={{ width: firstHalfSegmentWidth, }} diff --git a/web/src/components/timeline/ReviewTimeline.tsx b/web/src/components/timeline/ReviewTimeline.tsx index 896fea8e1..b0c1bc16e 100644 --- a/web/src/components/timeline/ReviewTimeline.tsx +++ b/web/src/components/timeline/ReviewTimeline.tsx @@ -234,7 +234,7 @@ export function ReviewTimeline({ onTouchMove={handleMouseMove} onMouseUp={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) ? "cursor-grabbing" : "cursor-auto" diff --git a/web/src/components/timeline/SummarySegment.tsx b/web/src/components/timeline/SummarySegment.tsx new file mode 100644 index 000000000..77f30dbdd --- /dev/null +++ b/web/src/components/timeline/SummarySegment.tsx @@ -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 ( +
+ {severity.map((severityValue: number, index: number) => { + return ( + +
+
+
+
+ ); + })} +
+ ); +} + +export default SummarySegment; diff --git a/web/src/components/timeline/SummaryTimeline.tsx b/web/src/components/timeline/SummaryTimeline.tsx new file mode 100644 index 000000000..c2a5fa6d6 --- /dev/null +++ b/web/src/components/timeline/SummaryTimeline.tsx @@ -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; + timelineStart: number; + timelineEnd: number; + segmentDuration: number; + events: ReviewSegment[]; +}; + +export function SummaryTimeline({ + reviewTimelineRef, + timelineStart, + timelineEnd, + segmentDuration, + events, +}: SummaryTimelineProps) { + const summaryTimelineRef = useRef(null); + const visibleSectionRef = useRef(null); + const [segmentHeight, setSegmentHeight] = useState(0); + + const [isDragging, setIsDragging] = useState(false); + const [scrollStartPosition, setScrollStartPosition] = useState(0); + const [initialReviewTimelineScrollTop, setInitialReviewTimelineScrollTop] = + useState(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 ( + + ); + }); + } + }, [ + 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 | React.TouchEvent, + ) => { + // 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 | React.TouchEvent, + ) => { + // 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); + 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 ( +
+
+ {segments} +
+
+
+ ); +} + +export default SummaryTimeline; diff --git a/web/src/components/timeline/segment-metadata.tsx b/web/src/components/timeline/segment-metadata.tsx index 4c85f1971..a0f303175 100644 --- a/web/src/components/timeline/segment-metadata.tsx +++ b/web/src/components/timeline/segment-metadata.tsx @@ -32,7 +32,7 @@ export function MinimapBounds({ <> {isFirstSegmentInMinimap && (
{new Date(alignedMinimapStartTime * 1000).toLocaleTimeString([], { @@ -44,7 +44,7 @@ export function MinimapBounds({ )} {isLastSegmentInMinimap && ( -
+
{new Date(alignedMinimapEndTime * 1000).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", @@ -61,7 +61,7 @@ export function Tick({ timestamp, timestampSpread }: TickSegmentProps) {
{timestamp.getMinutes() % timestampSpread === 0 && timestamp.getSeconds() === 0 && diff --git a/web/src/hooks/use-draggable-element.ts b/web/src/hooks/use-draggable-element.ts index dfb7826bf..1bed502c6 100644 --- a/web/src/hooks/use-draggable-element.ts +++ b/web/src/hooks/use-draggable-element.ts @@ -40,7 +40,8 @@ function useDraggableElement({ }: DraggableElementProps) { const [clientYPosition, setClientYPosition] = useState(null); const [initialClickAdjustment, setInitialClickAdjustment] = useState(0); - const { alignStartDateToTimeline } = useTimelineUtils(segmentDuration); + const { alignStartDateToTimeline, getCumulativeScrollTop } = + useTimelineUtils(segmentDuration); const draggingAtTopEdge = useMemo(() => { if (clientYPosition && timelineRef.current) { @@ -125,15 +126,6 @@ function useDraggableElement({ [isDragging, setIsDragging], ); - const getCumulativeScrollTop = useCallback((element: HTMLElement | null) => { - let scrollTop = 0; - while (element) { - scrollTop += element.scrollTop; - element = element.parentElement; - } - return scrollTop; - }, []); - const timestampToPixels = useCallback( (time: number) => { const { scrollHeight: timelineHeight } = diff --git a/web/src/hooks/use-event-segment-utils.ts b/web/src/hooks/use-event-segment-utils.ts index 8901835f0..61bbe2119 100644 --- a/web/src/hooks/use-event-segment-utils.ts +++ b/web/src/hooks/use-event-segment-utils.ts @@ -58,11 +58,12 @@ export const useEventSegmentUtils = ( ); const highestSeverityValue = Math.max(...severityValues); - if ( - severityValues.includes(displaySeverityType) && - displaySeverityType !== highestSeverityValue - ) { - return [displaySeverityType, highestSeverityValue]; + if (severityValues.includes(displaySeverityType)) { + const otherSeverityValues = severityValues.filter( + (severity) => severity !== displaySeverityType, + ); + const highestOtherSeverityValue = Math.max(...otherSeverityValues); + return [displaySeverityType, highestOtherSeverityValue]; } else { return [highestSeverityValue]; } diff --git a/web/src/hooks/use-timeline-utils.ts b/web/src/hooks/use-timeline-utils.ts index e03541259..7b211b528 100644 --- a/web/src/hooks/use-timeline-utils.ts +++ b/web/src/hooks/use-timeline-utils.ts @@ -19,8 +19,18 @@ export const useTimelineUtils = (segmentDuration: number) => { [segmentDuration], ); + const getCumulativeScrollTop = useCallback((element: HTMLElement | null) => { + let scrollTop = 0; + while (element) { + scrollTop += element.scrollTop; + element = element.parentElement; + } + return scrollTop; + }, []); + return { alignEndDateToTimeline, alignStartDateToTimeline, + getCumulativeScrollTop, }; }; diff --git a/web/src/pages/UIPlayground.tsx b/web/src/pages/UIPlayground.tsx index 8f1faa3fb..c344e0d72 100644 --- a/web/src/pages/UIPlayground.tsx +++ b/web/src/pages/UIPlayground.tsx @@ -26,6 +26,7 @@ import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer"; import { Switch } from "@/components/ui/switch"; import { Label } from "@/components/ui/label"; import { useNavigate } from "react-router-dom"; +import SummaryTimeline from "@/components/timeline/SummaryTimeline"; // Color data const colors = [ @@ -125,6 +126,7 @@ const generateRandomEvent = (): ReviewSegment => { function UIPlayground() { const { data: config } = useSWR("config"); const contentRef = useRef(null); + const reviewTimelineRef = useRef(null); const [mockEvents, setMockEvents] = useState([]); const [mockMotionData, setMockMotionData] = useState([]); const [handlebarTime, setHandlebarTime] = useState( @@ -144,7 +146,7 @@ function UIPlayground() { const navigate = useNavigate(); useMemo(() => { - const initialEvents = Array.from({ length: 50 }, generateRandomEvent); + const initialEvents = Array.from({ length: 10 }, generateRandomEvent); setMockEvents(initialEvents); setMockMotionData(generateRandomMotionAudioData()); }, []); @@ -403,9 +405,21 @@ function UIPlayground() { 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 contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later + timelineRef={reviewTimelineRef} /> )}
+ {isEventsReviewTimeline && ( +
+ +
+ )}
diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 32cc5283e..c3abcb2a9 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -36,6 +36,7 @@ import { Button } from "@/components/ui/button"; import PreviewPlayer, { PreviewController, } from "@/components/player/PreviewPlayer"; +import SummaryTimeline from "@/components/timeline/SummaryTimeline"; import { RecordingStartingPoint } from "@/types/record"; type EventViewProps = { @@ -329,6 +330,8 @@ function DetectionReview({ onSelectReview, pullLatestData, }: DetectionReviewProps) { + const reviewTimelineRef = useRef(null); + const segmentDuration = 60; // review data @@ -458,7 +461,7 @@ function DetectionReview({ > {filter?.before == undefined && (
-
- +
+
+ +
+
+ +
); diff --git a/web/themes/theme-default.css b/web/themes/theme-default.css index 03cf77e31..19ee09bb3 100644 --- a/web/themes/theme-default.css +++ b/web/themes/theme-default.css @@ -22,16 +22,16 @@ --primary: 0 0% 100%; --primary-foreground: hsl(0, 0%, 0%); - --primary-foreground: 0, 0%, 0%; + --primary-foreground: 0 0% 0%; --secondary: hsl(0, 0%, 96%); - --secondary: 0, 0%, 96%; + --secondary: 0 0% 96%; --secondary-foreground: hsl(0, 0%, 45%); - --secondary-foreground: 0, 0%, 45%; + --secondary-foreground: 0 0% 45%; --secondary-highlight: hsl(0, 0%, 94%); - --secondary-highlight: 0, 0%, 94%; + --secondary-highlight: 0 0% 94%; --muted: hsl(210 40% 96.1%); --muted: 210 40% 96.1%; @@ -104,28 +104,28 @@ --popover-foreground: 210 40% 98%; --primary: hsl(0, 0%, 9%); - --primary: 0, 0%, 9%; + --primary: 0 0% 9%; --primary-foreground: hsl(0, 0%, 100%); - --primary-foreground: 0, 0%, 100%; + --primary-foreground: 0 0% 100%; --secondary: hsl(0, 0%, 15%); - --secondary: 0, 0%, 15%; + --secondary: 0 0% 15%; --secondary-foreground: hsl(0, 0%, 83%); - --secondary-foreground: 0, 0%, 83%; + --secondary-foreground: 0 0% 83%; --secondary-highlight: hsl(0, 0%, 25%); - --secondary-highlight: 0, 0%, 25%; + --secondary-highlight: 0 0% 25%; --muted: hsl(0, 0%, 8%); - --muted: 0, 0%, 8%; + --muted: 0 0% 8%; --muted-foreground: hsl(0, 0%, 32%); - --muted-foreground: 0, 0%, 32%; + --muted-foreground: 0 0% 32%; --accent: hsl(0, 0%, 15%); - --accent: 0, 0%, 15%; + --accent: 0 0% 15%; --accent-foreground: hsl(210 40% 98%); --accent-foreground: 210 40% 98%; @@ -137,7 +137,7 @@ --destructive-foreground: 210 40% 98%; --border: hsl(0, 0%, 32%); - --border: 0, 0%, 32%; + --border: 0 0% 32%; --input: hsl(217.2 32.6% 17.5%); --input: 217.2 32.6% 17.5%;