zoom animations

This commit is contained in:
Josh Hawkins 2024-12-04 08:36:38 -06:00
parent 63e21763ec
commit e6e792cb21
10 changed files with 119 additions and 31 deletions

View File

@ -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<HTMLDivElement>;
contentRef: RefObject<HTMLDivElement>;
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<HTMLDivElement>(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 (
<ReviewTimeline
timelineRef={selectedTimelineRef}
@ -160,6 +155,8 @@ export function EventReviewTimeline({
dense={dense}
segments={segmentTimes}
scrollToSegment={scrollToSegment}
isZooming={isZooming}
zoomDirection={zoomDirection}
>
<VirtualizedEventSegments
ref={virtualizedSegmentsRef}

View File

@ -6,7 +6,11 @@ import React, {
useEffect,
} from "react";
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { MotionData, ReviewSegment } from "@/types/review";
import {
MotionData,
ReviewSegment,
TimelineZoomDirection,
} from "@/types/review";
import ReviewTimeline from "./ReviewTimeline";
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
import {
@ -38,6 +42,8 @@ export type MotionReviewTimelineProps = {
timelineRef?: RefObject<HTMLDivElement>;
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<HTMLDivElement>(null);
const selectedTimelineRef = timelineRef || internalTimelineRef;
@ -172,6 +180,8 @@ export function MotionReviewTimeline({
dense={dense}
segments={segmentTimes}
scrollToSegment={scrollToSegment}
isZooming={isZooming}
zoomDirection={zoomDirection}
>
<VirtualizedMotionSegments
ref={virtualizedSegmentsRef}

View File

@ -1,6 +1,8 @@
import useDraggableElement from "@/hooks/use-draggable-element";
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
import { cn } from "@/lib/utils";
import { DraggableElement } from "@/types/draggable-element";
import { TimelineZoomDirection } from "@/types/review";
import {
ReactNode,
RefObject,
@ -32,6 +34,8 @@ export type ReviewTimelineProps = {
dense: boolean;
segments: number[];
scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => 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 (
<div
ref={timelineRef}
className={`no-scrollbar relative h-full select-none overflow-y-auto bg-secondary ${
className={cn(
"no-scrollbar relative h-full select-none overflow-y-auto bg-secondary transition-all duration-500 ease-in-out",
isZooming && zoomDirection === "in" && "animate-timeline-zoom-in",
isZooming && zoomDirection === "out" && "animate-timeline-zoom-out",
isDragging && (showHandlebar || showExportHandles)
? "cursor-grabbing"
: "cursor-auto"
}`}
: "cursor-auto",
)}
>
<div ref={segmentsRef} className="relative flex flex-col">
<div className="pointer-events-none absolute inset-x-0 top-0 z-20 h-[30px] w-full bg-gradient-to-b from-secondary to-transparent"></div>

View File

@ -9,7 +9,7 @@ import React, {
import { EventSegment } from "./EventSegment";
import { ReviewSegment, ReviewSeverity } from "@/types/review";
interface VirtualizedEventSegmentsProps {
type VirtualizedEventSegmentsProps = {
timelineRef: React.RefObject<HTMLDivElement>;
segments: number[];
events: ReviewSegment[];
@ -23,7 +23,7 @@ interface VirtualizedEventSegmentsProps {
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
dense: boolean;
alignStartDateToTimeline: (timestamp: number) => number;
}
};
export interface VirtualizedEventSegmentsRef {
scrollToSegment: (

View File

@ -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<HTMLDivElement>;
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<TimelineZoomDirection>(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 };
}

View File

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

View File

@ -67,3 +67,5 @@ export type ConsolidatedSegmentData = {
severity: ReviewSeverity | "empty";
reviewed: boolean;
};
export type TimelineZoomDirection = "in" | "out" | null;

View File

@ -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}
/>
)}
</div>
@ -1130,6 +1134,8 @@ function MotionReview({
setScrubbing(scrubbing);
}}
dense={isMobileOnly}
isZooming={false}
zoomDirection={null}
/>
) : (
<Skeleton className="size-full" />

View File

@ -632,6 +632,7 @@ export function RecordingView({
type TimelineProps = {
contentRef: MutableRefObject<HTMLDivElement | null>;
timelineRef?: MutableRefObject<HTMLDivElement | null>;
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<HTMLDivElement>(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 ? (
<MotionReviewTimeline
timelineRef={selectedTimelineRef}
segmentDuration={zoomSettings.segmentDuration}
timestampSpread={zoomSettings.timestampSpread}
timelineStart={timeRange.before}
@ -743,6 +751,8 @@ function Timeline({
motion_events={motionData ?? []}
contentRef={contentRef}
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
isZooming={isZooming}
zoomDirection={zoomDirection}
/>
) : (
<Skeleton className="size-full" />

View File

@ -32,6 +32,8 @@ module.exports = {
scale2: "scale2 3s ease-in-out infinite",
scale3: "scale3 3s ease-in-out infinite",
scale4: "scale4 3s ease-in-out infinite",
"timeline-zoom-in": "timeline-zoom-in 0.3s ease-out",
"timeline-zoom-out": "timeline-zoom-out 0.3s ease-out",
},
aspectRatio: {
wide: "32 / 9",
@ -140,6 +142,16 @@ module.exports = {
"40%, 60%": { transform: "scale(1.4)" },
"30%, 70%": { transform: "scale(1)" },
},
"timeline-zoom-in": {
"0%": { transform: "translateY(0)", opacity: "1" },
"50%": { transform: "translateY(0%)", opacity: "0.5" },
"100%": { transform: "translateY(0)", opacity: "1" },
},
"timeline-zoom-out": {
"0%": { transform: "translateY(0)", opacity: "1" },
"50%": { transform: "translateY(0%)", opacity: "0.5" },
"100%": { transform: "translateY(0)", opacity: "1" },
},
},
screens: {
xs: "480px",