diff --git a/web/src/components/timeline/EventReviewTimeline.tsx b/web/src/components/timeline/EventReviewTimeline.tsx index 4160cd244..27f318edb 100644 --- a/web/src/components/timeline/EventReviewTimeline.tsx +++ b/web/src/components/timeline/EventReviewTimeline.tsx @@ -6,7 +6,11 @@ import React, { 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 { VirtualizedEventSegments, @@ -35,6 +39,8 @@ export type EventReviewTimelineProps = { timelineRef?: RefObject; contentRef: RefObject; onHandlebarDraggingChange?: (isDragging: boolean) => void; + isZooming: boolean; + zoomDirection: TimelineZoomDirection; dense?: boolean; }; @@ -60,6 +66,8 @@ export function EventReviewTimeline({ timelineRef, contentRef, onHandlebarDraggingChange, + isZooming, + zoomDirection, dense = false, }: EventReviewTimelineProps) { const internalTimelineRef = useRef(null); @@ -128,19 +136,6 @@ export function EventReviewTimeline({ [], ); - // 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 ( ; onHandlebarDraggingChange?: (isDragging: boolean) => void; dense?: boolean; + isZooming: boolean; + zoomDirection: TimelineZoomDirection; }; export function MotionReviewTimeline({ @@ -64,6 +70,8 @@ export function MotionReviewTimeline({ timelineRef, onHandlebarDraggingChange, dense = false, + isZooming, + zoomDirection, }: MotionReviewTimelineProps) { const internalTimelineRef = useRef(null); const selectedTimelineRef = timelineRef || internalTimelineRef; @@ -172,6 +180,8 @@ export function MotionReviewTimeline({ dense={dense} segments={segmentTimes} scrollToSegment={scrollToSegment} + isZooming={isZooming} + zoomDirection={zoomDirection} > void; + isZooming: boolean; + zoomDirection: TimelineZoomDirection; children: ReactNode; }; @@ -55,6 +59,8 @@ export function ReviewTimeline({ dense, segments, scrollToSegment, + isZooming, + zoomDirection, children, }: ReviewTimelineProps) { const [isDraggingHandlebar, setIsDraggingHandlebar] = useState(false); @@ -323,11 +329,14 @@ export function ReviewTimeline({ return (
diff --git a/web/src/components/timeline/VirtualizedEventSegments.tsx b/web/src/components/timeline/VirtualizedEventSegments.tsx index 6b610523c..5b8b73633 100644 --- a/web/src/components/timeline/VirtualizedEventSegments.tsx +++ b/web/src/components/timeline/VirtualizedEventSegments.tsx @@ -9,7 +9,7 @@ import React, { import { EventSegment } from "./EventSegment"; import { ReviewSegment, ReviewSeverity } from "@/types/review"; -interface VirtualizedEventSegmentsProps { +type VirtualizedEventSegmentsProps = { timelineRef: React.RefObject; segments: number[]; events: ReviewSegment[]; @@ -23,7 +23,7 @@ interface VirtualizedEventSegmentsProps { setHandlebarTime?: React.Dispatch>; dense: boolean; alignStartDateToTimeline: (timestamp: number) => number; -} +}; export interface VirtualizedEventSegmentsRef { scrollToSegment: ( diff --git a/web/src/hooks/use-timeline-zoom.ts b/web/src/hooks/use-timeline-zoom.ts index c8390bbe6..bcd996f77 100644 --- a/web/src/hooks/use-timeline-zoom.ts +++ b/web/src/hooks/use-timeline-zoom.ts @@ -1,4 +1,5 @@ import { useState, useEffect, useCallback, useRef } from "react"; +import { TimelineZoomDirection } from "@/types/review"; type ZoomSettings = { segmentDuration: number; @@ -10,6 +11,8 @@ type UseTimelineZoomProps = { zoomLevels: ZoomSettings[]; onZoomChange: (newZoomLevel: number) => void; pinchThresholdPercent?: number; + timelineRef: React.RefObject; + timelineDuration: number; }; export function useTimelineZoom({ @@ -17,6 +20,8 @@ export function useTimelineZoom({ zoomLevels, onZoomChange, pinchThresholdPercent = 20, + timelineRef, + timelineDuration, }: UseTimelineZoomProps) { const [zoomLevel, setZoomLevel] = useState( zoomLevels.findIndex( @@ -25,6 +30,9 @@ export function useTimelineZoom({ level.timestampSpread === zoomSettings.timestampSpread, ), ); + const [isZooming, setIsZooming] = useState(false); + const [zoomDirection, setZoomDirection] = + useState(null); const touchStartDistanceRef = useRef(0); const getPinchThreshold = useCallback(() => { @@ -37,18 +45,46 @@ export function useTimelineZoom({ 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) { + 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], + [zoomLevels, onZoomChange, timelineRef, timelineDuration], ); const debouncedZoom = useCallback(() => { @@ -134,5 +170,5 @@ export function useTimelineZoom({ }; }, [handleWheel, handleTouchStart, handleTouchMove]); - return { zoomLevel, handleZoom }; + return { zoomLevel, handleZoom, isZooming, zoomDirection }; } diff --git a/web/src/pages/UIPlayground.tsx b/web/src/pages/UIPlayground.tsx index b4f7cee0e..083e9c3c1 100644 --- a/web/src/pages/UIPlayground.tsx +++ b/web/src/pages/UIPlayground.tsx @@ -195,10 +195,12 @@ function UIPlayground() { [possibleZoomLevels], ); - const { zoomLevel, handleZoom } = useTimelineZoom({ + const { zoomLevel, handleZoom, isZooming, zoomDirection } = useTimelineZoom({ zoomSettings, zoomLevels: possibleZoomLevels, onZoomChange: handleZoomChange, + timelineRef: reviewTimelineRef, + timelineDuration: 4 * 60 * 60, }); const handleZoomIn = () => handleZoom(-1); @@ -407,6 +409,8 @@ function UIPlayground() { motion_events={mockMotionData} contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later dense={isMobile} // dense will produce a smaller handlebar and only minute resolution on timestamps + isZooming={isZooming} // is the timeline actively zooming? + zoomDirection={zoomDirection} // is the timeline zooming in or out /> )} {isEventsReviewTimeline && ( @@ -432,6 +436,8 @@ function UIPlayground() { contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later timelineRef={reviewTimelineRef} // save a ref to this timeline to connect with the summary timeline dense // dense will produce a smaller handlebar and only minute resolution on timestamps + isZooming={isZooming} // is the timeline actively zooming? + zoomDirection={zoomDirection} // is the timeline zooming in or out /> )}
diff --git a/web/src/types/review.ts b/web/src/types/review.ts index cbfc3826d..dbbd471fd 100644 --- a/web/src/types/review.ts +++ b/web/src/types/review.ts @@ -67,3 +67,5 @@ export type ConsolidatedSegmentData = { severity: ReviewSeverity | "empty"; reviewed: boolean; }; + +export type TimelineZoomDirection = "in" | "out" | null; diff --git a/web/src/views/events/EventView.tsx b/web/src/views/events/EventView.tsx index 00024aa45..cb2994322 100644 --- a/web/src/views/events/EventView.tsx +++ b/web/src/views/events/EventView.tsx @@ -486,6 +486,11 @@ function DetectionReview({ // timeline interaction + const timelineDuration = useMemo( + () => timeRange.before - timeRange.after, + [timeRange], + ); + const [zoomSettings, setZoomSettings] = useState({ segmentDuration: 60, timestampSpread: 15, @@ -507,17 +512,14 @@ function DetectionReview({ [possibleZoomLevels], ); - useTimelineZoom({ + const { isZooming, zoomDirection } = useTimelineZoom({ zoomSettings, zoomLevels: possibleZoomLevels, onZoomChange: handleZoomChange, + timelineRef: reviewTimelineRef, + timelineDuration, }); - const timelineDuration = useMemo( - () => timeRange.before - timeRange.after, - [timeRange], - ); - const { alignStartDateToTimeline, getVisibleTimelineDuration } = useTimelineUtils({ segmentDuration: zoomSettings.segmentDuration, @@ -795,6 +797,8 @@ function DetectionReview({ contentRef={contentRef} timelineRef={reviewTimelineRef} dense={isMobile} + isZooming={isZooming} + zoomDirection={zoomDirection} /> )}
@@ -1130,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 4a2c07ba2..1ba4d1bd7 100644 --- a/web/src/views/recording/RecordingView.tsx +++ b/web/src/views/recording/RecordingView.tsx @@ -632,6 +632,7 @@ export function RecordingView({ type TimelineProps = { contentRef: MutableRefObject; + timelineRef?: MutableRefObject; mainCamera: string; timelineType: TimelineType; timeRange: TimeRange; @@ -645,6 +646,7 @@ type TimelineProps = { }; function Timeline({ contentRef, + timelineRef, mainCamera, timelineType, timeRange, @@ -656,6 +658,9 @@ function Timeline({ setScrubbing, setExportRange, }: TimelineProps) { + const internalTimelineRef = useRef(null); + const selectedTimelineRef = timelineRef || internalTimelineRef; + // timeline interaction const [zoomSettings, setZoomSettings] = useState({ @@ -679,10 +684,12 @@ function Timeline({ [possibleZoomLevels], ); - useTimelineZoom({ + const { isZooming, zoomDirection } = useTimelineZoom({ zoomSettings, zoomLevels: possibleZoomLevels, onZoomChange: handleZoomChange, + timelineRef: selectedTimelineRef, + timelineDuration: timeRange.after - timeRange.before, }); // motion data @@ -727,6 +734,7 @@ function Timeline({ {timelineType == "timeline" ? ( !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",