diff --git a/web/src/components/timeline/EventReviewTimeline.tsx b/web/src/components/timeline/EventReviewTimeline.tsx index 9d5a4f70c..27f318edb 100644 --- a/web/src/components/timeline/EventReviewTimeline.tsx +++ b/web/src/components/timeline/EventReviewTimeline.tsx @@ -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; contentRef: RefObject; 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(null); const selectedTimelineRef = timelineRef || internalTimelineRef; + const virtualizedSegmentsRef = useRef(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 ( - - ); - }); - // 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 ( - {segments} + ); } diff --git a/web/src/components/timeline/EventSegment.tsx b/web/src/components/timeline/EventSegment.tsx index 242ca3248..b6bd9d137 100644 --- a/web/src/components/timeline/EventSegment.tsx +++ b/web/src/components/timeline/EventSegment.tsx @@ -8,7 +8,6 @@ import React, { useEffect, useMemo, useRef, - useState, } from "react"; import { HoverCard, @@ -31,6 +30,7 @@ type EventSegmentProps = { severityType: ReviewSeverity; contentRef: RefObject; setHandlebarTime?: React.Dispatch>; + 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(null); - const segmentRef = useRef(null); - - useEffect(() => { - const segmentObserver = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting && !segmentRendered) { - setSegmentRendered(true); - } - }, - { threshold: 0 }, - ); - - if (segmentRef.current) { - segmentObserver.observe(segmentRef.current); - } - - segmentObserverRef.current = segmentObserver; - - return () => { - if (segmentObserverRef.current) { - segmentObserverRef.current.disconnect(); - } - }; - }, [segmentRendered]); - - if (!segmentRendered) { - return ( -
- ); - } - return (
handleTouchStart(event, segmentClick)} diff --git a/web/src/components/timeline/MotionReviewTimeline.tsx b/web/src/components/timeline/MotionReviewTimeline.tsx index 157e53749..c8ef5ea75 100644 --- a/web/src/components/timeline/MotionReviewTimeline.tsx +++ b/web/src/components/timeline/MotionReviewTimeline.tsx @@ -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>; events: ReviewSegment[]; motion_events: MotionData[]; - severityType: ReviewSeverity; contentRef: RefObject; timelineRef?: RefObject; 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(null); const selectedTimelineRef = timelineRef || internalTimelineRef; + const virtualizedSegmentsRef = useRef(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( - , - ); 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 ( - {segments} + ); } diff --git a/web/src/components/timeline/MotionSegment.tsx b/web/src/components/timeline/MotionSegment.tsx index 903916b97..fa6fdbd80 100644 --- a/web/src/components/timeline/MotionSegment.tsx +++ b/web/src/components/timeline/MotionSegment.tsx @@ -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>; + 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(null); - const segmentRef = useRef(null); - - useEffect(() => { - const segmentObserver = new IntersectionObserver( - ([entry]) => { - if (entry.isIntersecting && !segmentRendered) { - setSegmentRendered(true); - } - }, - { threshold: 0 }, - ); - - if (segmentRef.current) { - segmentObserver.observe(segmentRef.current); - } - - segmentObserverRef.current = segmentObserver; - - return () => { - if (segmentObserverRef.current) { - segmentObserverRef.current.disconnect(); - } - }; - }, [segmentRendered]); - - if (!segmentRendered) { - return ( -
- ); - } - return ( <> {(((firstHalfSegmentWidth > 0 || secondHalfSegmentWidth > 0) && @@ -209,8 +166,7 @@ export function MotionSegment({ !motionOnly) && (
>; 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 (
{children}
- {children.length > 0 && ( + {children && ( <> {showHandlebar && (
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 ( -
- {severity.map((severityValue: number, index: number) => ( - - {severityValue === displaySeverityType && ( -
-
-
+
+
+
- ))} + >
+
); } diff --git a/web/src/components/timeline/SummaryTimeline.tsx b/web/src/components/timeline/SummaryTimeline.tsx index 7e67b9da6..d17709bdf 100644 --- a/web/src/components/timeline/SummaryTimeline.tsx +++ b/web/src/components/timeline/SummaryTimeline.tsx @@ -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; + reviewTimelineRef: React.RefObject; timelineStart: number; timelineEnd: number; segmentDuration: number; @@ -29,7 +32,6 @@ export function SummaryTimeline({ }: SummaryTimelineProps) { const summaryTimelineRef = useRef(null); const visibleSectionRef = useRef(null); - const [segmentHeight, setSegmentHeight] = useState(0); const [isDragging, setIsDragging] = useState(false); const [scrollStartPosition, setScrollStartPosition] = useState(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 ( - - ); + 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 | React.TouchEvent, @@ -344,11 +353,17 @@ export function SummaryTimeline({ >
- {segments} + {consolidatedSegments.map((segment, index) => ( + + ))}
; + segments: number[]; + events: ReviewSegment[]; + segmentDuration: number; + timestampSpread: number; + showMinimap: boolean; + minimapStartTime?: number; + minimapEndTime?: number; + severityType: ReviewSeverity; + contentRef: React.RefObject; + setHandlebarTime?: React.Dispatch>; + 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(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 ( +
+
+ {visibleRange.start > 0 && ( + + ); + }, +); diff --git a/web/src/components/timeline/VirtualizedMotionSegments.tsx b/web/src/components/timeline/VirtualizedMotionSegments.tsx new file mode 100644 index 000000000..3aed75266 --- /dev/null +++ b/web/src/components/timeline/VirtualizedMotionSegments.tsx @@ -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; + segments: number[]; + events: ReviewSegment[]; + motion_events: MotionData[]; + segmentDuration: number; + timestampSpread: number; + showMinimap: boolean; + minimapStartTime?: number; + minimapEndTime?: number; + contentRef: React.RefObject; + setHandlebarTime?: React.Dispatch>; + 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(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 ( +
+ +
+ ); + }, + [ + 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 ( +
+
+ {visibleRange.start > 0 && ( + + ); + }, +); + +export default VirtualizedMotionSegments; diff --git a/web/src/components/timeline/segment-metadata.tsx b/web/src/components/timeline/segment-metadata.tsx index 3e3c99393..9d29c73ad 100644 --- a/web/src/components/timeline/segment-metadata.tsx +++ b/web/src/components/timeline/segment-metadata.tsx @@ -22,7 +22,7 @@ type TimestampSegmentProps = { isLastSegmentInMinimap: boolean; timestamp: Date; timestampSpread: number; - segmentKey: number; + segmentKey: string; }; export function MinimapBounds({ diff --git a/web/src/hooks/use-draggable-element.ts b/web/src/hooks/use-draggable-element.ts index 73013de58..ddc8851b6 100644 --- a/web/src/hooks/use-draggable-element.ts +++ b/web/src/hooks/use-draggable-element.ts @@ -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>; setDraggableElementPosition?: React.Dispatch>; 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("config"); @@ -66,7 +60,6 @@ function useDraggableElement({ const [elementScrollIntoView, setElementScrollIntoView] = useState(true); const [scrollEdgeSize, setScrollEdgeSize] = useState(); const [fullTimelineHeight, setFullTimelineHeight] = useState(); - const [segments, setSegments] = useState([]); 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); } } } diff --git a/web/src/hooks/use-timeline-zoom.ts b/web/src/hooks/use-timeline-zoom.ts new file mode 100644 index 000000000..bcd996f77 --- /dev/null +++ b/web/src/hooks/use-timeline-zoom.ts @@ -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; + 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(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(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 }; +} diff --git a/web/src/pages/UIPlayground.tsx b/web/src/pages/UIPlayground.tsx index 39f8d3629..083e9c3c1 100644 --- a/web/src/pages/UIPlayground.tsx +++ b/web/src/pages/UIPlayground.tsx @@ -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() {
+ zoom level: {zoomLevel}

-
+
{loading ? ( ) : ( )}
@@ -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} /> ) : ( diff --git a/web/src/views/recording/RecordingView.tsx b/web/src/views/recording/RecordingView.tsx index 374201f7c..1ba4d1bd7 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -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; + timelineRef?: MutableRefObject; 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([ + const internalTimelineRef = useRef(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([ "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({
{timelineType == "timeline" ? ( - motionData ? ( + !isLoading ? ( setScrubbing(scrubbing)} + isZooming={isZooming} + zoomDirection={zoomDirection} /> ) : ( diff --git a/web/tailwind.config.js b/web/tailwind.config.js index b7371a193..4a341a3df 100644 --- a/web/tailwind.config.js +++ b/web/tailwind.config.js @@ -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",