mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-26 13:47:03 +02:00
Timeline improvements (#16429)
* virtualize event segments * use virtual segments in event review timeline * add segmentkey to props * virtualize motion segments * use virtual segments in motion review timeline * update draggable element hook to use only math * timeline zooming hook * add zooming to event review timeline * update playground * zoomable timeline on recording view * consolidate divs in summary timeline * only calculate motion data for visible motion segments * use swr loading state * fix motion only * keep handlebar centered when zooming * zoom animations * clean up * ensure motion only checks both halves of segment * prevent handlebar jump when using motion only mode
This commit is contained in:
parent
1f89844c67
commit
cc2dbdcb44
@ -1,9 +1,21 @@
|
|||||||
import { useEffect, useCallback, useMemo, useRef, RefObject } from "react";
|
import React, {
|
||||||
import EventSegment from "./EventSegment";
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
RefObject,
|
||||||
|
useCallback,
|
||||||
|
} from "react";
|
||||||
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
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 ReviewTimeline from "./ReviewTimeline";
|
||||||
import scrollIntoView from "scroll-into-view-if-needed";
|
import {
|
||||||
|
VirtualizedEventSegments,
|
||||||
|
VirtualizedEventSegmentsRef,
|
||||||
|
} from "./VirtualizedEventSegments";
|
||||||
|
|
||||||
export type EventReviewTimelineProps = {
|
export type EventReviewTimelineProps = {
|
||||||
segmentDuration: number;
|
segmentDuration: number;
|
||||||
@ -27,6 +39,8 @@ export type EventReviewTimelineProps = {
|
|||||||
timelineRef?: RefObject<HTMLDivElement>;
|
timelineRef?: RefObject<HTMLDivElement>;
|
||||||
contentRef: RefObject<HTMLDivElement>;
|
contentRef: RefObject<HTMLDivElement>;
|
||||||
onHandlebarDraggingChange?: (isDragging: boolean) => void;
|
onHandlebarDraggingChange?: (isDragging: boolean) => void;
|
||||||
|
isZooming: boolean;
|
||||||
|
zoomDirection: TimelineZoomDirection;
|
||||||
dense?: boolean;
|
dense?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -52,10 +66,13 @@ export function EventReviewTimeline({
|
|||||||
timelineRef,
|
timelineRef,
|
||||||
contentRef,
|
contentRef,
|
||||||
onHandlebarDraggingChange,
|
onHandlebarDraggingChange,
|
||||||
|
isZooming,
|
||||||
|
zoomDirection,
|
||||||
dense = false,
|
dense = false,
|
||||||
}: EventReviewTimelineProps) {
|
}: EventReviewTimelineProps) {
|
||||||
const internalTimelineRef = useRef<HTMLDivElement>(null);
|
const internalTimelineRef = useRef<HTMLDivElement>(null);
|
||||||
const selectedTimelineRef = timelineRef || internalTimelineRef;
|
const selectedTimelineRef = timelineRef || internalTimelineRef;
|
||||||
|
const virtualizedSegmentsRef = useRef<VirtualizedEventSegmentsRef>(null);
|
||||||
|
|
||||||
const timelineDuration = useMemo(
|
const timelineDuration = useMemo(
|
||||||
() => timelineStart - timelineEnd,
|
() => timelineStart - timelineEnd,
|
||||||
@ -73,79 +90,27 @@ export function EventReviewTimeline({
|
|||||||
[timelineStart, alignStartDateToTimeline],
|
[timelineStart, alignStartDateToTimeline],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate segments for the timeline
|
// Generate segment times for the timeline
|
||||||
const generateSegments = useCallback(() => {
|
const segmentTimes = useMemo(() => {
|
||||||
const segmentCount = Math.ceil(timelineDuration / segmentDuration);
|
const segmentCount = Math.ceil(timelineDuration / segmentDuration);
|
||||||
|
return Array.from(
|
||||||
return Array.from({ length: segmentCount }, (_, index) => {
|
{ length: segmentCount },
|
||||||
const segmentTime = timelineStartAligned - index * segmentDuration;
|
(_, index) => timelineStartAligned - index * segmentDuration,
|
||||||
|
|
||||||
return (
|
|
||||||
<EventSegment
|
|
||||||
key={segmentTime + severityType}
|
|
||||||
events={events}
|
|
||||||
segmentDuration={segmentDuration}
|
|
||||||
segmentTime={segmentTime}
|
|
||||||
timestampSpread={timestampSpread}
|
|
||||||
showMinimap={showMinimap}
|
|
||||||
minimapStartTime={minimapStartTime}
|
|
||||||
minimapEndTime={minimapEndTime}
|
|
||||||
severityType={severityType}
|
|
||||||
contentRef={contentRef}
|
|
||||||
setHandlebarTime={setHandlebarTime}
|
|
||||||
dense={dense}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
// 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,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
}, [timelineDuration, segmentDuration, timelineStartAligned]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
selectedTimelineRef.current &&
|
|
||||||
segments &&
|
|
||||||
visibleTimestamps &&
|
visibleTimestamps &&
|
||||||
visibleTimestamps?.length > 0 &&
|
visibleTimestamps.length > 0 &&
|
||||||
!showMinimap
|
!showMinimap &&
|
||||||
|
virtualizedSegmentsRef.current
|
||||||
) {
|
) {
|
||||||
const alignedVisibleTimestamps = visibleTimestamps.map(
|
const alignedVisibleTimestamps = visibleTimestamps.map(
|
||||||
alignStartDateToTimeline,
|
alignStartDateToTimeline,
|
||||||
);
|
);
|
||||||
const element = selectedTimelineRef.current?.querySelector(
|
|
||||||
`[data-segment-id="${Math.max(...alignedVisibleTimestamps)}"]`,
|
scrollToSegment(Math.max(...alignedVisibleTimestamps), true);
|
||||||
);
|
|
||||||
if (element) {
|
|
||||||
scrollIntoView(element, {
|
|
||||||
scrollMode: "if-needed",
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
// don't scroll when segments update from unreviewed -> reviewed
|
// don't scroll when segments update from unreviewed -> reviewed
|
||||||
// we know that these deps are correct
|
// we know that these deps are correct
|
||||||
@ -155,8 +120,22 @@ export function EventReviewTimeline({
|
|||||||
showMinimap,
|
showMinimap,
|
||||||
alignStartDateToTimeline,
|
alignStartDateToTimeline,
|
||||||
visibleTimestamps,
|
visibleTimestamps,
|
||||||
|
segmentDuration,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
const scrollToSegment = useCallback(
|
||||||
|
(segmentTime: number, ifNeeded?: boolean, behavior?: ScrollBehavior) => {
|
||||||
|
if (virtualizedSegmentsRef.current) {
|
||||||
|
virtualizedSegmentsRef.current.scrollToSegment(
|
||||||
|
segmentTime,
|
||||||
|
ifNeeded,
|
||||||
|
behavior,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ReviewTimeline
|
<ReviewTimeline
|
||||||
timelineRef={selectedTimelineRef}
|
timelineRef={selectedTimelineRef}
|
||||||
@ -174,8 +153,27 @@ export function EventReviewTimeline({
|
|||||||
setExportStartTime={setExportStartTime}
|
setExportStartTime={setExportStartTime}
|
||||||
setExportEndTime={setExportEndTime}
|
setExportEndTime={setExportEndTime}
|
||||||
dense={dense}
|
dense={dense}
|
||||||
|
segments={segmentTimes}
|
||||||
|
scrollToSegment={scrollToSegment}
|
||||||
|
isZooming={isZooming}
|
||||||
|
zoomDirection={zoomDirection}
|
||||||
>
|
>
|
||||||
{segments}
|
<VirtualizedEventSegments
|
||||||
|
ref={virtualizedSegmentsRef}
|
||||||
|
timelineRef={selectedTimelineRef}
|
||||||
|
segments={segmentTimes}
|
||||||
|
events={events}
|
||||||
|
segmentDuration={segmentDuration}
|
||||||
|
timestampSpread={timestampSpread}
|
||||||
|
showMinimap={showMinimap}
|
||||||
|
minimapStartTime={minimapStartTime}
|
||||||
|
minimapEndTime={minimapEndTime}
|
||||||
|
severityType={severityType}
|
||||||
|
contentRef={contentRef}
|
||||||
|
setHandlebarTime={setHandlebarTime}
|
||||||
|
dense={dense}
|
||||||
|
alignStartDateToTimeline={alignStartDateToTimeline}
|
||||||
|
/>
|
||||||
</ReviewTimeline>
|
</ReviewTimeline>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -8,7 +8,6 @@ import React, {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useMemo,
|
useMemo,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
|
||||||
} from "react";
|
} from "react";
|
||||||
import {
|
import {
|
||||||
HoverCard,
|
HoverCard,
|
||||||
@ -31,6 +30,7 @@ type EventSegmentProps = {
|
|||||||
severityType: ReviewSeverity;
|
severityType: ReviewSeverity;
|
||||||
contentRef: RefObject<HTMLDivElement>;
|
contentRef: RefObject<HTMLDivElement>;
|
||||||
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
|
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void;
|
||||||
dense: boolean;
|
dense: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -45,6 +45,7 @@ export function EventSegment({
|
|||||||
severityType,
|
severityType,
|
||||||
contentRef,
|
contentRef,
|
||||||
setHandlebarTime,
|
setHandlebarTime,
|
||||||
|
scrollToSegment,
|
||||||
dense,
|
dense,
|
||||||
}: EventSegmentProps) {
|
}: EventSegmentProps) {
|
||||||
const {
|
const {
|
||||||
@ -95,7 +96,10 @@ export function EventSegment({
|
|||||||
}, [getEventThumbnail, segmentTime]);
|
}, [getEventThumbnail, segmentTime]);
|
||||||
|
|
||||||
const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]);
|
const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]);
|
||||||
const segmentKey = useMemo(() => segmentTime, [segmentTime]);
|
const segmentKey = useMemo(
|
||||||
|
() => `${segmentTime}_${segmentDuration}`,
|
||||||
|
[segmentTime, segmentDuration],
|
||||||
|
);
|
||||||
|
|
||||||
const alignedMinimapStartTime = useMemo(
|
const alignedMinimapStartTime = useMemo(
|
||||||
() => alignStartDateToTimeline(minimapStartTime ?? 0),
|
() => alignStartDateToTimeline(minimapStartTime ?? 0),
|
||||||
@ -133,10 +137,7 @@ export function EventSegment({
|
|||||||
// Check if the first segment is out of view
|
// Check if the first segment is out of view
|
||||||
const firstSegment = firstMinimapSegmentRef.current;
|
const firstSegment = firstMinimapSegmentRef.current;
|
||||||
if (firstSegment && showMinimap && isFirstSegmentInMinimap) {
|
if (firstSegment && showMinimap && isFirstSegmentInMinimap) {
|
||||||
scrollIntoView(firstSegment, {
|
scrollToSegment(alignedMinimapStartTime);
|
||||||
scrollMode: "if-needed",
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// we know that these deps are correct
|
// we know that these deps are correct
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -196,49 +197,10 @@ export function EventSegment({
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [startTimestamp]);
|
}, [startTimestamp]);
|
||||||
|
|
||||||
const [segmentRendered, setSegmentRendered] = useState(false);
|
|
||||||
const segmentObserverRef = useRef<IntersectionObserver | null>(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 (
|
||||||
<div
|
<div
|
||||||
key={segmentKey}
|
key={segmentKey}
|
||||||
ref={segmentRef}
|
data-segment-id={segmentTime}
|
||||||
data-segment-id={segmentKey}
|
|
||||||
className={`segment ${segmentClasses}`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={segmentKey}
|
|
||||||
ref={segmentRef}
|
|
||||||
data-segment-id={segmentKey}
|
|
||||||
className={`segment ${segmentClasses}`}
|
className={`segment ${segmentClasses}`}
|
||||||
onClick={segmentClick}
|
onClick={segmentClick}
|
||||||
onTouchEnd={(event) => handleTouchStart(event, segmentClick)}
|
onTouchEnd={(event) => handleTouchStart(event, segmentClick)}
|
||||||
|
@ -1,9 +1,22 @@
|
|||||||
import { useCallback, useMemo, useRef, RefObject } from "react";
|
import React, {
|
||||||
import MotionSegment from "./MotionSegment";
|
useCallback,
|
||||||
|
useMemo,
|
||||||
|
useRef,
|
||||||
|
RefObject,
|
||||||
|
useEffect,
|
||||||
|
} from "react";
|
||||||
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
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 ReviewTimeline from "./ReviewTimeline";
|
||||||
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
|
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
|
||||||
|
import {
|
||||||
|
VirtualizedMotionSegments,
|
||||||
|
VirtualizedMotionSegmentsRef,
|
||||||
|
} from "./VirtualizedMotionSegments";
|
||||||
|
|
||||||
export type MotionReviewTimelineProps = {
|
export type MotionReviewTimelineProps = {
|
||||||
segmentDuration: number;
|
segmentDuration: number;
|
||||||
@ -25,11 +38,12 @@ export type MotionReviewTimelineProps = {
|
|||||||
setExportEndTime?: React.Dispatch<React.SetStateAction<number>>;
|
setExportEndTime?: React.Dispatch<React.SetStateAction<number>>;
|
||||||
events: ReviewSegment[];
|
events: ReviewSegment[];
|
||||||
motion_events: MotionData[];
|
motion_events: MotionData[];
|
||||||
severityType: ReviewSeverity;
|
|
||||||
contentRef: RefObject<HTMLDivElement>;
|
contentRef: RefObject<HTMLDivElement>;
|
||||||
timelineRef?: RefObject<HTMLDivElement>;
|
timelineRef?: RefObject<HTMLDivElement>;
|
||||||
onHandlebarDraggingChange?: (isDragging: boolean) => void;
|
onHandlebarDraggingChange?: (isDragging: boolean) => void;
|
||||||
dense?: boolean;
|
dense?: boolean;
|
||||||
|
isZooming: boolean;
|
||||||
|
zoomDirection: TimelineZoomDirection;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MotionReviewTimeline({
|
export function MotionReviewTimeline({
|
||||||
@ -56,9 +70,12 @@ export function MotionReviewTimeline({
|
|||||||
timelineRef,
|
timelineRef,
|
||||||
onHandlebarDraggingChange,
|
onHandlebarDraggingChange,
|
||||||
dense = false,
|
dense = false,
|
||||||
|
isZooming,
|
||||||
|
zoomDirection,
|
||||||
}: MotionReviewTimelineProps) {
|
}: MotionReviewTimelineProps) {
|
||||||
const internalTimelineRef = useRef<HTMLDivElement>(null);
|
const internalTimelineRef = useRef<HTMLDivElement>(null);
|
||||||
const selectedTimelineRef = timelineRef || internalTimelineRef;
|
const selectedTimelineRef = timelineRef || internalTimelineRef;
|
||||||
|
const virtualizedSegmentsRef = useRef<VirtualizedMotionSegmentsRef>(null);
|
||||||
|
|
||||||
const timelineDuration = useMemo(
|
const timelineDuration = useMemo(
|
||||||
() => timelineStart - timelineEnd + 4 * segmentDuration,
|
() => timelineStart - timelineEnd + 4 * segmentDuration,
|
||||||
@ -80,22 +97,16 @@ export function MotionReviewTimeline({
|
|||||||
motion_events,
|
motion_events,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate segments for the timeline
|
const segmentTimes = useMemo(() => {
|
||||||
const generateSegments = useCallback(() => {
|
|
||||||
const segments = [];
|
const segments = [];
|
||||||
let segmentTime = timelineStartAligned;
|
let segmentTime = timelineStartAligned;
|
||||||
|
|
||||||
while (segmentTime >= timelineStartAligned - timelineDuration) {
|
for (let i = 0; i < Math.ceil(timelineDuration / segmentDuration); i++) {
|
||||||
|
if (!motionOnly) {
|
||||||
|
segments.push(segmentTime);
|
||||||
|
} else {
|
||||||
const motionStart = segmentTime;
|
const motionStart = segmentTime;
|
||||||
const motionEnd = motionStart + segmentDuration;
|
const motionEnd = motionStart + segmentDuration;
|
||||||
|
|
||||||
const firstHalfMotionValue = getMotionSegmentValue(motionStart);
|
|
||||||
const secondHalfMotionValue = getMotionSegmentValue(
|
|
||||||
motionStart + segmentDuration / 2,
|
|
||||||
);
|
|
||||||
|
|
||||||
const segmentMotion =
|
|
||||||
firstHalfMotionValue > 0 || secondHalfMotionValue > 0;
|
|
||||||
const overlappingReviewItems = events.some(
|
const overlappingReviewItems = events.some(
|
||||||
(item) =>
|
(item) =>
|
||||||
(item.start_time >= motionStart && item.start_time < motionEnd) ||
|
(item.start_time >= motionStart && item.start_time < motionEnd) ||
|
||||||
@ -104,65 +115,56 @@ export function MotionReviewTimeline({
|
|||||||
(item.start_time <= motionStart &&
|
(item.start_time <= motionStart &&
|
||||||
(item.end_time ?? timelineStart) >= motionEnd),
|
(item.end_time ?? timelineStart) >= motionEnd),
|
||||||
);
|
);
|
||||||
|
const firstHalfMotionValue = getMotionSegmentValue(motionStart);
|
||||||
if ((!segmentMotion || overlappingReviewItems) && motionOnly) {
|
const secondHalfMotionValue = getMotionSegmentValue(
|
||||||
// exclude segment if necessary when in motion only mode
|
motionStart + segmentDuration / 2,
|
||||||
segmentTime -= segmentDuration;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
segments.push(
|
|
||||||
<MotionSegment
|
|
||||||
key={segmentTime}
|
|
||||||
events={events}
|
|
||||||
firstHalfMotionValue={firstHalfMotionValue}
|
|
||||||
secondHalfMotionValue={secondHalfMotionValue}
|
|
||||||
segmentDuration={segmentDuration}
|
|
||||||
segmentTime={segmentTime}
|
|
||||||
timestampSpread={timestampSpread}
|
|
||||||
motionOnly={motionOnly}
|
|
||||||
showMinimap={showMinimap}
|
|
||||||
minimapStartTime={minimapStartTime}
|
|
||||||
minimapEndTime={minimapEndTime}
|
|
||||||
setHandlebarTime={setHandlebarTime}
|
|
||||||
dense={dense}
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const segmentMotion =
|
||||||
|
firstHalfMotionValue > 0 || secondHalfMotionValue > 0;
|
||||||
|
if (segmentMotion && !overlappingReviewItems) {
|
||||||
|
segments.push(segmentTime);
|
||||||
|
}
|
||||||
|
}
|
||||||
segmentTime -= segmentDuration;
|
segmentTime -= segmentDuration;
|
||||||
}
|
}
|
||||||
|
|
||||||
return segments;
|
return segments;
|
||||||
// we know that these deps are correct
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [
|
}, [
|
||||||
segmentDuration,
|
|
||||||
timestampSpread,
|
|
||||||
timelineStartAligned,
|
timelineStartAligned,
|
||||||
|
segmentDuration,
|
||||||
timelineDuration,
|
timelineDuration,
|
||||||
showMinimap,
|
|
||||||
minimapStartTime,
|
|
||||||
minimapEndTime,
|
|
||||||
events,
|
|
||||||
motion_events,
|
|
||||||
motionOnly,
|
motionOnly,
|
||||||
|
getMotionSegmentValue,
|
||||||
|
events,
|
||||||
|
timelineStart,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const segments = useMemo(
|
const scrollToSegment = useCallback(
|
||||||
() => generateSegments(),
|
(segmentTime: number, ifNeeded?: boolean, behavior?: ScrollBehavior) => {
|
||||||
// we know that these deps are correct
|
if (virtualizedSegmentsRef.current) {
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
virtualizedSegmentsRef.current.scrollToSegment(
|
||||||
[
|
segmentTime,
|
||||||
segmentDuration,
|
ifNeeded,
|
||||||
timestampSpread,
|
behavior,
|
||||||
timelineStartAligned,
|
|
||||||
timelineDuration,
|
|
||||||
showMinimap,
|
|
||||||
minimapStartTime,
|
|
||||||
minimapEndTime,
|
|
||||||
events,
|
|
||||||
motion_events,
|
|
||||||
motionOnly,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<ReviewTimeline
|
<ReviewTimeline
|
||||||
@ -183,8 +185,28 @@ export function MotionReviewTimeline({
|
|||||||
setExportEndTime={setExportEndTime}
|
setExportEndTime={setExportEndTime}
|
||||||
timelineCollapsed={motionOnly}
|
timelineCollapsed={motionOnly}
|
||||||
dense={dense}
|
dense={dense}
|
||||||
|
segments={segmentTimes}
|
||||||
|
scrollToSegment={scrollToSegment}
|
||||||
|
isZooming={isZooming}
|
||||||
|
zoomDirection={zoomDirection}
|
||||||
>
|
>
|
||||||
{segments}
|
<VirtualizedMotionSegments
|
||||||
|
ref={virtualizedSegmentsRef}
|
||||||
|
timelineRef={selectedTimelineRef}
|
||||||
|
segments={segmentTimes}
|
||||||
|
events={events}
|
||||||
|
motion_events={motion_events}
|
||||||
|
segmentDuration={segmentDuration}
|
||||||
|
timestampSpread={timestampSpread}
|
||||||
|
showMinimap={showMinimap}
|
||||||
|
minimapStartTime={minimapStartTime}
|
||||||
|
minimapEndTime={minimapEndTime}
|
||||||
|
contentRef={contentRef}
|
||||||
|
setHandlebarTime={setHandlebarTime}
|
||||||
|
dense={dense}
|
||||||
|
motionOnly={motionOnly}
|
||||||
|
getMotionSegmentValue={getMotionSegmentValue}
|
||||||
|
/>
|
||||||
</ReviewTimeline>
|
</ReviewTimeline>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,7 @@
|
|||||||
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
||||||
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils";
|
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils";
|
||||||
import { ReviewSegment } from "@/types/review";
|
import { ReviewSegment } from "@/types/review";
|
||||||
import React, {
|
import React, { useCallback, useEffect, useMemo, useRef } from "react";
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import scrollIntoView from "scroll-into-view-if-needed";
|
|
||||||
import { MinimapBounds, Tick, Timestamp } from "./segment-metadata";
|
import { MinimapBounds, Tick, Timestamp } from "./segment-metadata";
|
||||||
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
|
import { useMotionSegmentUtils } from "@/hooks/use-motion-segment-utils";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
@ -27,6 +20,7 @@ type MotionSegmentProps = {
|
|||||||
minimapStartTime?: number;
|
minimapStartTime?: number;
|
||||||
minimapEndTime?: number;
|
minimapEndTime?: number;
|
||||||
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
|
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void;
|
||||||
dense: boolean;
|
dense: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -42,6 +36,7 @@ export function MotionSegment({
|
|||||||
minimapStartTime,
|
minimapStartTime,
|
||||||
minimapEndTime,
|
minimapEndTime,
|
||||||
setHandlebarTime,
|
setHandlebarTime,
|
||||||
|
scrollToSegment,
|
||||||
dense,
|
dense,
|
||||||
}: MotionSegmentProps) {
|
}: MotionSegmentProps) {
|
||||||
const severityType = "all";
|
const severityType = "all";
|
||||||
@ -72,7 +67,10 @@ export function MotionSegment({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]);
|
const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]);
|
||||||
const segmentKey = useMemo(() => segmentTime, [segmentTime]);
|
const segmentKey = useMemo(
|
||||||
|
() => `${segmentTime}_${segmentDuration}`,
|
||||||
|
[segmentTime, segmentDuration],
|
||||||
|
);
|
||||||
|
|
||||||
const maxSegmentWidth = useMemo(() => {
|
const maxSegmentWidth = useMemo(() => {
|
||||||
return isMobile ? 30 : 50;
|
return isMobile ? 30 : 50;
|
||||||
@ -122,10 +120,7 @@ export function MotionSegment({
|
|||||||
// Check if the first segment is out of view
|
// Check if the first segment is out of view
|
||||||
const firstSegment = firstMinimapSegmentRef.current;
|
const firstSegment = firstMinimapSegmentRef.current;
|
||||||
if (firstSegment && showMinimap && isFirstSegmentInMinimap) {
|
if (firstSegment && showMinimap && isFirstSegmentInMinimap) {
|
||||||
scrollIntoView(firstSegment, {
|
scrollToSegment(alignedMinimapStartTime);
|
||||||
scrollMode: "if-needed",
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// we know that these deps are correct
|
// we know that these deps are correct
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
@ -163,44 +158,6 @@ export function MotionSegment({
|
|||||||
}
|
}
|
||||||
}, [segmentTime, setHandlebarTime]);
|
}, [segmentTime, setHandlebarTime]);
|
||||||
|
|
||||||
const [segmentRendered, setSegmentRendered] = useState(false);
|
|
||||||
const segmentObserverRef = useRef<IntersectionObserver | null>(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 (
|
|
||||||
<div
|
|
||||||
key={segmentKey}
|
|
||||||
ref={segmentRef}
|
|
||||||
data-segment-id={segmentKey}
|
|
||||||
className={`segment ${segmentClasses}`}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{(((firstHalfSegmentWidth > 0 || secondHalfSegmentWidth > 0) &&
|
{(((firstHalfSegmentWidth > 0 || secondHalfSegmentWidth > 0) &&
|
||||||
@ -209,8 +166,7 @@ export function MotionSegment({
|
|||||||
!motionOnly) && (
|
!motionOnly) && (
|
||||||
<div
|
<div
|
||||||
key={segmentKey}
|
key={segmentKey}
|
||||||
data-segment-id={segmentKey}
|
data-segment-id={segmentTime}
|
||||||
ref={segmentRef}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"segment",
|
"segment",
|
||||||
{
|
{
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
import useDraggableElement from "@/hooks/use-draggable-element";
|
import useDraggableElement from "@/hooks/use-draggable-element";
|
||||||
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
import { DraggableElement } from "@/types/draggable-element";
|
import { DraggableElement } from "@/types/draggable-element";
|
||||||
|
import { TimelineZoomDirection } from "@/types/review";
|
||||||
import {
|
import {
|
||||||
ReactNode,
|
ReactNode,
|
||||||
RefObject,
|
RefObject,
|
||||||
@ -30,7 +32,11 @@ export type ReviewTimelineProps = {
|
|||||||
setExportEndTime?: React.Dispatch<React.SetStateAction<number>>;
|
setExportEndTime?: React.Dispatch<React.SetStateAction<number>>;
|
||||||
timelineCollapsed?: boolean;
|
timelineCollapsed?: boolean;
|
||||||
dense: boolean;
|
dense: boolean;
|
||||||
children: ReactNode[];
|
segments: number[];
|
||||||
|
scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void;
|
||||||
|
isZooming: boolean;
|
||||||
|
zoomDirection: TimelineZoomDirection;
|
||||||
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function ReviewTimeline({
|
export function ReviewTimeline({
|
||||||
@ -51,6 +57,10 @@ export function ReviewTimeline({
|
|||||||
setExportEndTime,
|
setExportEndTime,
|
||||||
timelineCollapsed = false,
|
timelineCollapsed = false,
|
||||||
dense,
|
dense,
|
||||||
|
segments,
|
||||||
|
scrollToSegment,
|
||||||
|
isZooming,
|
||||||
|
zoomDirection,
|
||||||
children,
|
children,
|
||||||
}: ReviewTimelineProps) {
|
}: ReviewTimelineProps) {
|
||||||
const [isDraggingHandlebar, setIsDraggingHandlebar] = useState(false);
|
const [isDraggingHandlebar, setIsDraggingHandlebar] = useState(false);
|
||||||
@ -116,7 +126,8 @@ export function ReviewTimeline({
|
|||||||
setIsDragging: setIsDraggingHandlebar,
|
setIsDragging: setIsDraggingHandlebar,
|
||||||
draggableElementTimeRef: handlebarTimeRef,
|
draggableElementTimeRef: handlebarTimeRef,
|
||||||
dense,
|
dense,
|
||||||
timelineSegments: children,
|
segments,
|
||||||
|
scrollToSegment,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -140,7 +151,8 @@ export function ReviewTimeline({
|
|||||||
draggableElementTimeRef: exportStartTimeRef,
|
draggableElementTimeRef: exportStartTimeRef,
|
||||||
setDraggableElementPosition: setExportStartPosition,
|
setDraggableElementPosition: setExportStartPosition,
|
||||||
dense,
|
dense,
|
||||||
timelineSegments: children,
|
segments,
|
||||||
|
scrollToSegment,
|
||||||
});
|
});
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@ -164,7 +176,8 @@ export function ReviewTimeline({
|
|||||||
draggableElementTimeRef: exportEndTimeRef,
|
draggableElementTimeRef: exportEndTimeRef,
|
||||||
setDraggableElementPosition: setExportEndPosition,
|
setDraggableElementPosition: setExportEndPosition,
|
||||||
dense,
|
dense,
|
||||||
timelineSegments: children,
|
segments,
|
||||||
|
scrollToSegment,
|
||||||
});
|
});
|
||||||
|
|
||||||
const handleHandlebar = useCallback(
|
const handleHandlebar = useCallback(
|
||||||
@ -316,18 +329,21 @@ export function ReviewTimeline({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={timelineRef}
|
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)
|
isDragging && (showHandlebar || showExportHandles)
|
||||||
? "cursor-grabbing"
|
? "cursor-grabbing"
|
||||||
: "cursor-auto"
|
: "cursor-auto",
|
||||||
}`}
|
)}
|
||||||
>
|
>
|
||||||
<div ref={segmentsRef} className="relative flex flex-col">
|
<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>
|
<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>
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 h-[30px] w-full bg-gradient-to-t from-secondary to-transparent"></div>
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 h-[30px] w-full bg-gradient-to-t from-secondary to-transparent"></div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
{children.length > 0 && (
|
{children && (
|
||||||
<>
|
<>
|
||||||
{showHandlebar && (
|
{showHandlebar && (
|
||||||
<div
|
<div
|
||||||
|
@ -1,68 +1,39 @@
|
|||||||
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
import { ConsolidatedSegmentData } from "@/types/review";
|
||||||
import React, { useMemo } from "react";
|
|
||||||
// import useTapUtils from "@/hooks/use-tap-utils";
|
|
||||||
|
|
||||||
type SummarySegmentProps = {
|
type SummarySegmentProps = {
|
||||||
events: ReviewSegment[];
|
segmentData: ConsolidatedSegmentData;
|
||||||
segmentTime: number;
|
totalDuration: number;
|
||||||
segmentDuration: number;
|
|
||||||
segmentHeight: number;
|
|
||||||
severityType: ReviewSeverity;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export function SummarySegment({
|
export function SummarySegment({
|
||||||
events,
|
segmentData,
|
||||||
segmentTime,
|
totalDuration,
|
||||||
segmentDuration,
|
|
||||||
segmentHeight,
|
|
||||||
severityType,
|
|
||||||
}: SummarySegmentProps) {
|
}: SummarySegmentProps) {
|
||||||
const { getSeverity, getReviewed, displaySeverityType } =
|
const { startTime, endTime, severity, reviewed } = segmentData;
|
||||||
useEventSegmentUtils(segmentDuration, events, severityType);
|
|
||||||
|
|
||||||
const severity = useMemo(
|
const severityColors: { [key: string]: string } = {
|
||||||
() => getSeverity(segmentTime, displaySeverityType),
|
significant_motion: reviewed
|
||||||
[getSeverity, segmentTime, displaySeverityType],
|
|
||||||
);
|
|
||||||
|
|
||||||
const reviewed = useMemo(
|
|
||||||
() => getReviewed(segmentTime),
|
|
||||||
[getReviewed, segmentTime],
|
|
||||||
);
|
|
||||||
|
|
||||||
const segmentKey = useMemo(() => segmentTime, [segmentTime]);
|
|
||||||
|
|
||||||
const severityColors: { [key: number]: string } = {
|
|
||||||
1: reviewed
|
|
||||||
? "bg-severity_significant_motion/50"
|
? "bg-severity_significant_motion/50"
|
||||||
: "bg-severity_significant_motion",
|
: "bg-severity_significant_motion",
|
||||||
2: reviewed ? "bg-severity_detection/50" : "bg-severity_detection",
|
detection: reviewed ? "bg-severity_detection/50" : "bg-severity_detection",
|
||||||
3: reviewed ? "bg-severity_alert/50" : "bg-severity_alert",
|
alert: reviewed ? "bg-severity_alert/50" : "bg-severity_alert",
|
||||||
|
empty: "bg-transparent",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const height = ((endTime - startTime) / totalDuration) * 100;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div className="relative w-full" style={{ height: `${height}%` }}>
|
||||||
|
<div className="absolute inset-0 flex h-full cursor-pointer justify-end">
|
||||||
<div
|
<div
|
||||||
key={segmentKey}
|
className={cn(
|
||||||
className="relative w-full"
|
"w-[10px]",
|
||||||
style={{ height: segmentHeight }}
|
severityColors[severity],
|
||||||
>
|
height < 0.5 && "min-h-[0.5px]",
|
||||||
{severity.map((severityValue: number, index: number) => (
|
)}
|
||||||
<React.Fragment key={index}>
|
|
||||||
{severityValue === displaySeverityType && (
|
|
||||||
<div
|
|
||||||
className="flex cursor-pointer justify-end"
|
|
||||||
style={{ height: segmentHeight }}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
key={`${segmentKey}_${index}_secondary_data`}
|
|
||||||
style={{ height: segmentHeight }}
|
|
||||||
className={`w-[10px] ${severityColors[severityValue]}`}
|
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</React.Fragment>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,17 +1,20 @@
|
|||||||
import {
|
import React, {
|
||||||
RefObject,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
|
useMemo,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { SummarySegment } from "./SummarySegment";
|
import { SummarySegment } from "./SummarySegment";
|
||||||
|
import {
|
||||||
|
ConsolidatedSegmentData,
|
||||||
|
ReviewSegment,
|
||||||
|
ReviewSeverity,
|
||||||
|
} from "@/types/review";
|
||||||
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
||||||
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
|
||||||
|
|
||||||
export type SummaryTimelineProps = {
|
export type SummaryTimelineProps = {
|
||||||
reviewTimelineRef: RefObject<HTMLDivElement>;
|
reviewTimelineRef: React.RefObject<HTMLDivElement>;
|
||||||
timelineStart: number;
|
timelineStart: number;
|
||||||
timelineEnd: number;
|
timelineEnd: number;
|
||||||
segmentDuration: number;
|
segmentDuration: number;
|
||||||
@ -29,7 +32,6 @@ export function SummaryTimeline({
|
|||||||
}: SummaryTimelineProps) {
|
}: SummaryTimelineProps) {
|
||||||
const summaryTimelineRef = useRef<HTMLDivElement>(null);
|
const summaryTimelineRef = useRef<HTMLDivElement>(null);
|
||||||
const visibleSectionRef = useRef<HTMLDivElement>(null);
|
const visibleSectionRef = useRef<HTMLDivElement>(null);
|
||||||
const [segmentHeight, setSegmentHeight] = useState(0);
|
|
||||||
|
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const [scrollStartPosition, setScrollStartPosition] = useState<number>(0);
|
const [scrollStartPosition, setScrollStartPosition] = useState<number>(0);
|
||||||
@ -43,61 +45,89 @@ export function SummaryTimeline({
|
|||||||
[timelineEnd, timelineStart, segmentDuration],
|
[timelineEnd, timelineStart, segmentDuration],
|
||||||
);
|
);
|
||||||
|
|
||||||
const { alignStartDateToTimeline } = useTimelineUtils({
|
const { alignStartDateToTimeline, alignEndDateToTimeline } = useTimelineUtils(
|
||||||
|
{
|
||||||
segmentDuration,
|
segmentDuration,
|
||||||
timelineDuration: reviewTimelineDuration,
|
timelineDuration: reviewTimelineDuration,
|
||||||
timelineRef: reviewTimelineRef,
|
timelineRef: reviewTimelineRef,
|
||||||
});
|
},
|
||||||
|
|
||||||
const timelineStartAligned = useMemo(
|
|
||||||
() => alignStartDateToTimeline(timelineStart) + 2 * segmentDuration,
|
|
||||||
[timelineStart, alignStartDateToTimeline, segmentDuration],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// Generate segments for the timeline
|
const consolidatedSegments = useMemo(() => {
|
||||||
const generateSegments = useCallback(() => {
|
const filteredEvents = events.filter(
|
||||||
const segmentCount = Math.ceil(reviewTimelineDuration / segmentDuration);
|
(event) => event.severity === severityType,
|
||||||
|
|
||||||
if (segmentHeight) {
|
|
||||||
return Array.from({ length: segmentCount }, (_, index) => {
|
|
||||||
const segmentTime = timelineStartAligned - index * segmentDuration;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SummarySegment
|
|
||||||
key={segmentTime}
|
|
||||||
events={events}
|
|
||||||
segmentDuration={segmentDuration}
|
|
||||||
segmentTime={segmentTime}
|
|
||||||
segmentHeight={segmentHeight}
|
|
||||||
severityType={severityType}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sortedEvents = filteredEvents.sort(
|
||||||
|
(a, b) => a.start_time - b.start_time,
|
||||||
|
);
|
||||||
|
|
||||||
|
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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [
|
|
||||||
segmentDuration,
|
|
||||||
timelineStartAligned,
|
|
||||||
events,
|
|
||||||
reviewTimelineDuration,
|
|
||||||
segmentHeight,
|
|
||||||
severityType,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const segments = useMemo(
|
consolidated.push({
|
||||||
() => generateSegments(),
|
startTime: alignedStartTime,
|
||||||
// we know that these deps are correct
|
endTime: alignedEndTime,
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
severity: event.severity,
|
||||||
[
|
reviewed: event.has_been_reviewed,
|
||||||
segmentDuration,
|
});
|
||||||
segmentHeight,
|
|
||||||
timelineStartAligned,
|
currentTime = alignedEndTime;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (currentTime < timelineStartAligned) {
|
||||||
|
consolidated.push({
|
||||||
|
startTime: currentTime,
|
||||||
|
endTime: timelineStartAligned,
|
||||||
|
severity: "empty",
|
||||||
|
reviewed: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return consolidated.length > 0
|
||||||
|
? consolidated
|
||||||
|
: [
|
||||||
|
{
|
||||||
|
startTime: alignEndDateToTimeline(timelineEnd),
|
||||||
|
endTime: timelineStartAligned,
|
||||||
|
severity: "empty" as const,
|
||||||
|
reviewed: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [
|
||||||
events,
|
events,
|
||||||
reviewTimelineDuration,
|
|
||||||
segmentHeight,
|
|
||||||
generateSegments,
|
|
||||||
severityType,
|
severityType,
|
||||||
],
|
timelineStart,
|
||||||
);
|
timelineEnd,
|
||||||
|
alignStartDateToTimeline,
|
||||||
|
alignEndDateToTimeline,
|
||||||
|
segmentDuration,
|
||||||
|
]);
|
||||||
|
|
||||||
const setVisibleSectionStyles = useCallback(() => {
|
const setVisibleSectionStyles = useCallback(() => {
|
||||||
if (
|
if (
|
||||||
@ -137,15 +167,6 @@ export function SummaryTimeline({
|
|||||||
|
|
||||||
observer.current = new ResizeObserver(() => {
|
observer.current = new ResizeObserver(() => {
|
||||||
setVisibleSectionStyles();
|
setVisibleSectionStyles();
|
||||||
if (summaryTimelineRef.current) {
|
|
||||||
const { clientHeight: summaryTimelineVisibleHeight } =
|
|
||||||
summaryTimelineRef.current;
|
|
||||||
|
|
||||||
setSegmentHeight(
|
|
||||||
summaryTimelineVisibleHeight /
|
|
||||||
(reviewTimelineDuration / segmentDuration),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
observer.current.observe(content);
|
observer.current.observe(content);
|
||||||
|
|
||||||
@ -163,18 +184,6 @@ export function SummaryTimeline({
|
|||||||
segmentDuration,
|
segmentDuration,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (summaryTimelineRef.current) {
|
|
||||||
const { clientHeight: summaryTimelineVisibleHeight } =
|
|
||||||
summaryTimelineRef.current;
|
|
||||||
|
|
||||||
setSegmentHeight(
|
|
||||||
summaryTimelineVisibleHeight /
|
|
||||||
(reviewTimelineDuration / segmentDuration),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}, [reviewTimelineDuration, summaryTimelineRef, segmentDuration]);
|
|
||||||
|
|
||||||
const timelineClick = useCallback(
|
const timelineClick = useCallback(
|
||||||
(
|
(
|
||||||
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,
|
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,
|
||||||
@ -344,11 +353,17 @@ export function SummaryTimeline({
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={summaryTimelineRef}
|
ref={summaryTimelineRef}
|
||||||
className="relative z-10 flex h-full flex-col"
|
className="relative z-10 flex h-full flex-col-reverse"
|
||||||
onClick={timelineClick}
|
onClick={timelineClick}
|
||||||
onTouchEnd={timelineClick}
|
onTouchEnd={timelineClick}
|
||||||
>
|
>
|
||||||
{segments}
|
{consolidatedSegments.map((segment, index) => (
|
||||||
|
<SummarySegment
|
||||||
|
key={`${segment.startTime}-${segment.endTime}+${index}`}
|
||||||
|
segmentData={segment}
|
||||||
|
totalDuration={timelineStart - timelineEnd}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
ref={visibleSectionRef}
|
ref={visibleSectionRef}
|
||||||
|
213
web/src/components/timeline/VirtualizedEventSegments.tsx
Normal file
213
web/src/components/timeline/VirtualizedEventSegments.tsx
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
import React, {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
forwardRef,
|
||||||
|
useImperativeHandle,
|
||||||
|
} from "react";
|
||||||
|
import { EventSegment } from "./EventSegment";
|
||||||
|
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||||
|
|
||||||
|
type VirtualizedEventSegmentsProps = {
|
||||||
|
timelineRef: React.RefObject<HTMLDivElement>;
|
||||||
|
segments: number[];
|
||||||
|
events: ReviewSegment[];
|
||||||
|
segmentDuration: number;
|
||||||
|
timestampSpread: number;
|
||||||
|
showMinimap: boolean;
|
||||||
|
minimapStartTime?: number;
|
||||||
|
minimapEndTime?: number;
|
||||||
|
severityType: ReviewSeverity;
|
||||||
|
contentRef: React.RefObject<HTMLDivElement>;
|
||||||
|
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="h-full w-full"
|
||||||
|
style={{ position: "relative", willChange: "transform" }}
|
||||||
|
>
|
||||||
|
<div style={{ height: `${totalHeight}px`, position: "relative" }}>
|
||||||
|
{visibleRange.start > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
height: `${visibleRange.start * SEGMENT_HEIGHT}px`,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{visibleSegments.map((segmentTime, index) => {
|
||||||
|
const segmentId = `${segmentTime}_${segmentDuration}`;
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={segmentId}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: `${(visibleRange.start + index) * SEGMENT_HEIGHT}px`,
|
||||||
|
height: `${SEGMENT_HEIGHT}px`,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<EventSegment
|
||||||
|
events={events}
|
||||||
|
segmentTime={segmentTime}
|
||||||
|
segmentDuration={segmentDuration}
|
||||||
|
timestampSpread={timestampSpread}
|
||||||
|
showMinimap={showMinimap}
|
||||||
|
minimapStartTime={minimapStartTime}
|
||||||
|
minimapEndTime={minimapEndTime}
|
||||||
|
severityType={severityType}
|
||||||
|
contentRef={contentRef}
|
||||||
|
setHandlebarTime={setHandlebarTime}
|
||||||
|
scrollToSegment={scrollToSegment}
|
||||||
|
dense={dense}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{visibleRange.end < segments.length && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: `${visibleRange.end * SEGMENT_HEIGHT}px`,
|
||||||
|
height: `${(segments.length - visibleRange.end) * SEGMENT_HEIGHT}px`,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
251
web/src/components/timeline/VirtualizedMotionSegments.tsx
Normal file
251
web/src/components/timeline/VirtualizedMotionSegments.tsx
Normal file
@ -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<HTMLDivElement>;
|
||||||
|
segments: number[];
|
||||||
|
events: ReviewSegment[];
|
||||||
|
motion_events: MotionData[];
|
||||||
|
segmentDuration: number;
|
||||||
|
timestampSpread: number;
|
||||||
|
showMinimap: boolean;
|
||||||
|
minimapStartTime?: number;
|
||||||
|
minimapEndTime?: number;
|
||||||
|
contentRef: React.RefObject<HTMLDivElement>;
|
||||||
|
setHandlebarTime?: React.Dispatch<React.SetStateAction<number>>;
|
||||||
|
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<HTMLDivElement>(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 (
|
||||||
|
<div
|
||||||
|
key={`${segmentTime}_${segmentDuration}`}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: `${(visibleRange.start + index) * SEGMENT_HEIGHT}px`,
|
||||||
|
height: `${SEGMENT_HEIGHT}px`,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MotionSegment
|
||||||
|
events={events}
|
||||||
|
firstHalfMotionValue={firstHalfMotionValue}
|
||||||
|
secondHalfMotionValue={secondHalfMotionValue}
|
||||||
|
segmentDuration={segmentDuration}
|
||||||
|
segmentTime={segmentTime}
|
||||||
|
timestampSpread={timestampSpread}
|
||||||
|
motionOnly={motionOnly}
|
||||||
|
showMinimap={showMinimap}
|
||||||
|
minimapStartTime={minimapStartTime}
|
||||||
|
minimapEndTime={minimapEndTime}
|
||||||
|
setHandlebarTime={setHandlebarTime}
|
||||||
|
scrollToSegment={scrollToSegment}
|
||||||
|
dense={dense}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="h-full w-full"
|
||||||
|
style={{ position: "relative", willChange: "transform" }}
|
||||||
|
>
|
||||||
|
<div style={{ height: `${totalHeight}px`, position: "relative" }}>
|
||||||
|
{visibleRange.start > 0 && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: 0,
|
||||||
|
height: `${visibleRange.start * SEGMENT_HEIGHT}px`,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{visibleSegments.map((segmentTime, index) =>
|
||||||
|
renderSegment(segmentTime, index),
|
||||||
|
)}
|
||||||
|
{visibleRange.end < segments.length && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
top: `${visibleRange.end * SEGMENT_HEIGHT}px`,
|
||||||
|
height: `${
|
||||||
|
(segments.length - visibleRange.end) * SEGMENT_HEIGHT
|
||||||
|
}px`,
|
||||||
|
width: "100%",
|
||||||
|
}}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
export default VirtualizedMotionSegments;
|
@ -22,7 +22,7 @@ type TimestampSegmentProps = {
|
|||||||
isLastSegmentInMinimap: boolean;
|
isLastSegmentInMinimap: boolean;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
timestampSpread: number;
|
timestampSpread: number;
|
||||||
segmentKey: number;
|
segmentKey: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function MinimapBounds({
|
export function MinimapBounds({
|
||||||
|
@ -1,12 +1,4 @@
|
|||||||
import {
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
ReactNode,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import scrollIntoView from "scroll-into-view-if-needed";
|
|
||||||
import { useTimelineUtils } from "./use-timeline-utils";
|
import { useTimelineUtils } from "./use-timeline-utils";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
@ -33,7 +25,8 @@ type DraggableElementProps = {
|
|||||||
setIsDragging: React.Dispatch<React.SetStateAction<boolean>>;
|
setIsDragging: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
setDraggableElementPosition?: React.Dispatch<React.SetStateAction<number>>;
|
setDraggableElementPosition?: React.Dispatch<React.SetStateAction<number>>;
|
||||||
dense: boolean;
|
dense: boolean;
|
||||||
timelineSegments: ReactNode[];
|
segments: number[];
|
||||||
|
scrollToSegment: (segmentTime: number, ifNeeded?: boolean) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
function useDraggableElement({
|
function useDraggableElement({
|
||||||
@ -57,7 +50,8 @@ function useDraggableElement({
|
|||||||
setIsDragging,
|
setIsDragging,
|
||||||
setDraggableElementPosition,
|
setDraggableElementPosition,
|
||||||
dense,
|
dense,
|
||||||
timelineSegments,
|
segments,
|
||||||
|
scrollToSegment,
|
||||||
}: DraggableElementProps) {
|
}: DraggableElementProps) {
|
||||||
const { data: config } = useSWR<FrigateConfig>("config");
|
const { data: config } = useSWR<FrigateConfig>("config");
|
||||||
|
|
||||||
@ -66,7 +60,6 @@ function useDraggableElement({
|
|||||||
const [elementScrollIntoView, setElementScrollIntoView] = useState(true);
|
const [elementScrollIntoView, setElementScrollIntoView] = useState(true);
|
||||||
const [scrollEdgeSize, setScrollEdgeSize] = useState<number>();
|
const [scrollEdgeSize, setScrollEdgeSize] = useState<number>();
|
||||||
const [fullTimelineHeight, setFullTimelineHeight] = useState<number>();
|
const [fullTimelineHeight, setFullTimelineHeight] = useState<number>();
|
||||||
const [segments, setSegments] = useState<HTMLDivElement[]>([]);
|
|
||||||
const { alignStartDateToTimeline, getCumulativeScrollTop, segmentHeight } =
|
const { alignStartDateToTimeline, getCumulativeScrollTop, segmentHeight } =
|
||||||
useTimelineUtils({
|
useTimelineUtils({
|
||||||
segmentDuration: segmentDuration,
|
segmentDuration: segmentDuration,
|
||||||
@ -201,11 +194,7 @@ function useDraggableElement({
|
|||||||
draggableElementTimeRef.current.textContent =
|
draggableElementTimeRef.current.textContent =
|
||||||
getFormattedTimestamp(segmentStartTime);
|
getFormattedTimestamp(segmentStartTime);
|
||||||
if (scrollTimeline && !userInteracting) {
|
if (scrollTimeline && !userInteracting) {
|
||||||
scrollIntoView(thumb, {
|
scrollToSegment(segmentStartTime);
|
||||||
block: "center",
|
|
||||||
behavior: "smooth",
|
|
||||||
scrollMode: "if-needed",
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -222,6 +211,7 @@ function useDraggableElement({
|
|||||||
setDraggableElementPosition,
|
setDraggableElementPosition,
|
||||||
getFormattedTimestamp,
|
getFormattedTimestamp,
|
||||||
userInteracting,
|
userInteracting,
|
||||||
|
scrollToSegment,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@ -241,12 +231,6 @@ function useDraggableElement({
|
|||||||
[contentRef, draggableElementRef, timelineRef, getClientYPosition],
|
[contentRef, draggableElementRef, timelineRef, getClientYPosition],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (timelineRef.current && timelineSegments.length) {
|
|
||||||
setSegments(Array.from(timelineRef.current.querySelectorAll(".segment")));
|
|
||||||
}
|
|
||||||
}, [timelineRef, timelineCollapsed, timelineSegments]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let animationFrameId: number | null = null;
|
let animationFrameId: number | null = null;
|
||||||
|
|
||||||
@ -256,7 +240,7 @@ function useDraggableElement({
|
|||||||
showDraggableElement &&
|
showDraggableElement &&
|
||||||
isDragging &&
|
isDragging &&
|
||||||
clientYPosition &&
|
clientYPosition &&
|
||||||
segments &&
|
segments.length > 0 &&
|
||||||
fullTimelineHeight
|
fullTimelineHeight
|
||||||
) {
|
) {
|
||||||
const { scrollTop: scrolled } = timelineRef.current;
|
const { scrollTop: scrolled } = timelineRef.current;
|
||||||
@ -295,31 +279,18 @@ function useDraggableElement({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let targetSegmentId = 0;
|
const start = Math.max(0, Math.floor(scrolled / segmentHeight));
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
segments.forEach((segmentElement: HTMLDivElement) => {
|
const relativePosition = newElementPosition - scrolled;
|
||||||
const rect = segmentElement.getBoundingClientRect();
|
const segmentIndex =
|
||||||
const segmentTop =
|
Math.floor(relativePosition / segmentHeight) + start + 1;
|
||||||
rect.top + scrolled - timelineTopAbsolute - segmentHeight;
|
|
||||||
const segmentBottom =
|
|
||||||
rect.bottom + scrolled - timelineTopAbsolute - segmentHeight;
|
|
||||||
|
|
||||||
// Check if handlebar position falls within the segment bounds
|
const targetSegmentTime = segments[segmentIndex];
|
||||||
if (
|
if (targetSegmentTime === undefined) return;
|
||||||
newElementPosition >= segmentTop &&
|
|
||||||
newElementPosition <= segmentBottom
|
const segmentStart = segmentIndex * segmentHeight - scrolled;
|
||||||
) {
|
|
||||||
targetSegmentId = parseFloat(
|
const offset = Math.min(segmentStart - relativePosition, segmentHeight);
|
||||||
segmentElement.getAttribute("data-segment-id") || "0",
|
|
||||||
);
|
|
||||||
offset = Math.min(
|
|
||||||
segmentBottom - newElementPosition,
|
|
||||||
segmentHeight,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if ((draggingAtTopEdge || draggingAtBottomEdge) && scrollEdgeSize) {
|
if ((draggingAtTopEdge || draggingAtBottomEdge) && scrollEdgeSize) {
|
||||||
if (draggingAtTopEdge) {
|
if (draggingAtTopEdge) {
|
||||||
@ -349,8 +320,8 @@ function useDraggableElement({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const setTime = alignSetTimeToSegment
|
const setTime = alignSetTimeToSegment
|
||||||
? targetSegmentId
|
? targetSegmentTime
|
||||||
: targetSegmentId + segmentDuration * (offset / segmentHeight);
|
: targetSegmentTime + segmentDuration * (offset / segmentHeight);
|
||||||
|
|
||||||
updateDraggableElementPosition(
|
updateDraggableElementPosition(
|
||||||
newElementPosition,
|
newElementPosition,
|
||||||
@ -361,7 +332,7 @@ function useDraggableElement({
|
|||||||
|
|
||||||
if (setDraggableElementTime) {
|
if (setDraggableElementTime) {
|
||||||
setDraggableElementTime(
|
setDraggableElementTime(
|
||||||
targetSegmentId + segmentDuration * (offset / segmentHeight),
|
targetSegmentTime + segmentDuration * (offset / segmentHeight),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -397,6 +368,7 @@ function useDraggableElement({
|
|||||||
draggingAtTopEdge,
|
draggingAtTopEdge,
|
||||||
draggingAtBottomEdge,
|
draggingAtBottomEdge,
|
||||||
showDraggableElement,
|
showDraggableElement,
|
||||||
|
segments,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -408,24 +380,23 @@ function useDraggableElement({
|
|||||||
!isDragging &&
|
!isDragging &&
|
||||||
segments.length > 0
|
segments.length > 0
|
||||||
) {
|
) {
|
||||||
const { scrollTop: scrolled } = timelineRef.current;
|
|
||||||
|
|
||||||
const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime);
|
const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime);
|
||||||
|
if (!userInteracting) {
|
||||||
|
scrollToSegment(alignedSegmentTime);
|
||||||
|
}
|
||||||
|
|
||||||
const segmentElement = timelineRef.current.querySelector(
|
const segmentIndex = segments.findIndex(
|
||||||
`[data-segment-id="${alignedSegmentTime}"]`,
|
(time) => time === alignedSegmentTime,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (segmentElement) {
|
if (segmentIndex >= 0) {
|
||||||
const timelineRect = timelineRef.current.getBoundingClientRect();
|
const segmentStart = segmentIndex * segmentHeight;
|
||||||
const timelineTopAbsolute = timelineRect.top;
|
|
||||||
const rect = segmentElement.getBoundingClientRect();
|
|
||||||
const segmentTop = rect.top + scrolled - timelineTopAbsolute;
|
|
||||||
const offset =
|
const offset =
|
||||||
((draggableElementTime - alignedSegmentTime) / segmentDuration) *
|
((draggableElementTime - alignedSegmentTime) / segmentDuration) *
|
||||||
segmentHeight;
|
segmentHeight;
|
||||||
// subtract half the height of the handlebar cross bar (4px) for pixel perfection
|
// subtract half the height of the handlebar cross bar (4px) for pixel perfection
|
||||||
const newElementPosition = segmentTop - offset - 2;
|
const newElementPosition = segmentStart - offset - 2;
|
||||||
|
|
||||||
updateDraggableElementPosition(
|
updateDraggableElementPosition(
|
||||||
newElementPosition,
|
newElementPosition,
|
||||||
@ -454,14 +425,27 @@ function useDraggableElement({
|
|||||||
segments,
|
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(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
timelineRef.current &&
|
timelineRef.current &&
|
||||||
segmentsRef.current &&
|
segmentsRef.current &&
|
||||||
draggableElementTime &&
|
draggableElementTime &&
|
||||||
timelineCollapsed &&
|
timelineCollapsed &&
|
||||||
timelineSegments &&
|
segments.length > 0
|
||||||
segments
|
|
||||||
) {
|
) {
|
||||||
setFullTimelineHeight(
|
setFullTimelineHeight(
|
||||||
Math.min(
|
Math.min(
|
||||||
@ -469,47 +453,27 @@ function useDraggableElement({
|
|||||||
segmentsRef.current.scrollHeight,
|
segmentsRef.current.scrollHeight,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime);
|
const alignedSegmentTime = alignStartDateToTimeline(draggableElementTime);
|
||||||
|
|
||||||
let segmentElement = timelineRef.current.querySelector(
|
if (segments.includes(alignedSegmentTime)) {
|
||||||
`[data-segment-id="${alignedSegmentTime}"]`,
|
scrollToSegment(alignedSegmentTime);
|
||||||
);
|
} else {
|
||||||
|
|
||||||
if (!segmentElement) {
|
|
||||||
// segment not found, maybe we collapsed over a collapsible segment
|
// segment not found, maybe we collapsed over a collapsible segment
|
||||||
let searchTime = alignedSegmentTime;
|
const nextAvailableSegment =
|
||||||
|
findNextAvailableSegment(alignedSegmentTime);
|
||||||
|
|
||||||
while (
|
if (nextAvailableSegment !== null) {
|
||||||
searchTime < timelineStartAligned &&
|
scrollToSegment(nextAvailableSegment);
|
||||||
searchTime < timelineStartAligned + timelineDuration
|
|
||||||
) {
|
|
||||||
searchTime += segmentDuration;
|
|
||||||
segmentElement = timelineRef.current.querySelector(
|
|
||||||
`[data-segment-id="${searchTime}"]`,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (segmentElement) {
|
|
||||||
// found, set time
|
|
||||||
if (setDraggableElementTime) {
|
if (setDraggableElementTime) {
|
||||||
setDraggableElementTime(searchTime);
|
setDraggableElementTime(nextAvailableSegment);
|
||||||
}
|
|
||||||
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 (setDraggableElementTime) {
|
|
||||||
setDraggableElementTime(searchTime);
|
|
||||||
}
|
}
|
||||||
} else {
|
} 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) {
|
if (setDraggableElementTime) {
|
||||||
setDraggableElementTime(timelineStartAligned);
|
setDraggableElementTime(firstAvailableSegment);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
174
web/src/hooks/use-timeline-zoom.ts
Normal file
174
web/src/hooks/use-timeline-zoom.ts
Normal file
@ -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<HTMLDivElement>;
|
||||||
|
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<TimelineZoomDirection>(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<NodeJS.Timeout | null>(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 };
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useRef, useState } from "react";
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import useSWR from "swr";
|
import useSWR from "swr";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
@ -29,6 +29,7 @@ import { useNavigate } from "react-router-dom";
|
|||||||
import SummaryTimeline from "@/components/timeline/SummaryTimeline";
|
import SummaryTimeline from "@/components/timeline/SummaryTimeline";
|
||||||
import { isMobile } from "react-device-detect";
|
import { isMobile } from "react-device-detect";
|
||||||
import IconPicker, { IconElement } from "@/components/icons/IconPicker";
|
import IconPicker, { IconElement } from "@/components/icons/IconPicker";
|
||||||
|
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
|
||||||
|
|
||||||
// Color data
|
// Color data
|
||||||
const colors = [
|
const colors = [
|
||||||
@ -173,32 +174,37 @@ function UIPlayground() {
|
|||||||
return Math.floor(Date.now() / 1000); // Default to current time if no events
|
return Math.floor(Date.now() / 1000); // Default to current time if no events
|
||||||
}, [mockEvents]);
|
}, [mockEvents]);
|
||||||
|
|
||||||
const [zoomLevel, setZoomLevel] = useState(0);
|
|
||||||
const [zoomSettings, setZoomSettings] = useState({
|
const [zoomSettings, setZoomSettings] = useState({
|
||||||
segmentDuration: 60,
|
segmentDuration: 60,
|
||||||
timestampSpread: 15,
|
timestampSpread: 15,
|
||||||
});
|
});
|
||||||
|
|
||||||
const possibleZoomLevels = [
|
const possibleZoomLevels = useMemo(
|
||||||
|
() => [
|
||||||
{ segmentDuration: 60, timestampSpread: 15 },
|
{ segmentDuration: 60, timestampSpread: 15 },
|
||||||
{ segmentDuration: 30, timestampSpread: 5 },
|
{ segmentDuration: 30, timestampSpread: 5 },
|
||||||
{ segmentDuration: 10, timestampSpread: 1 },
|
{ segmentDuration: 10, timestampSpread: 1 },
|
||||||
];
|
],
|
||||||
|
[],
|
||||||
function handleZoomIn() {
|
|
||||||
const nextZoomLevel = Math.min(
|
|
||||||
possibleZoomLevels.length - 1,
|
|
||||||
zoomLevel + 1,
|
|
||||||
);
|
);
|
||||||
setZoomLevel(nextZoomLevel);
|
|
||||||
setZoomSettings(possibleZoomLevels[nextZoomLevel]);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleZoomOut() {
|
const handleZoomChange = useCallback(
|
||||||
const nextZoomLevel = Math.max(0, zoomLevel - 1);
|
(newZoomLevel: number) => {
|
||||||
setZoomLevel(nextZoomLevel);
|
setZoomSettings(possibleZoomLevels[newZoomLevel]);
|
||||||
setZoomSettings(possibleZoomLevels[nextZoomLevel]);
|
},
|
||||||
}
|
[possibleZoomLevels],
|
||||||
|
);
|
||||||
|
|
||||||
|
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);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
@ -330,6 +336,7 @@ function UIPlayground() {
|
|||||||
<div className="my-4 w-[40px]">
|
<div className="my-4 w-[40px]">
|
||||||
<CameraActivityIndicator />
|
<CameraActivityIndicator />
|
||||||
</div>
|
</div>
|
||||||
|
zoom level: {zoomLevel}
|
||||||
<p>
|
<p>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
@ -367,7 +374,6 @@ function UIPlayground() {
|
|||||||
</a>{" "}
|
</a>{" "}
|
||||||
for usage.
|
for usage.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="my-5">
|
<div className="my-5">
|
||||||
{colors.map((color, index) => (
|
{colors.map((color, index) => (
|
||||||
<ColorSwatch
|
<ColorSwatch
|
||||||
@ -401,9 +407,10 @@ function UIPlayground() {
|
|||||||
setExportEndTime={setExportEndTime}
|
setExportEndTime={setExportEndTime}
|
||||||
events={mockEvents} // events, including new has_been_reviewed and severity properties
|
events={mockEvents} // events, including new has_been_reviewed and severity properties
|
||||||
motion_events={mockMotionData}
|
motion_events={mockMotionData}
|
||||||
severityType={"alert"} // choose the severity type for the middle line - all other severity types are to the right
|
|
||||||
contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later
|
contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later
|
||||||
dense={isMobile} // dense will produce a smaller handlebar and only minute resolution on timestamps
|
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 && (
|
{isEventsReviewTimeline && (
|
||||||
@ -429,6 +436,8 @@ function UIPlayground() {
|
|||||||
contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later
|
contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later
|
||||||
timelineRef={reviewTimelineRef} // save a ref to this timeline to connect with the summary timeline
|
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
|
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>
|
</div>
|
||||||
|
@ -60,3 +60,12 @@ export type MotionData = {
|
|||||||
export const REVIEW_PADDING = 4;
|
export const REVIEW_PADDING = 4;
|
||||||
|
|
||||||
export type ReviewDetailPaneType = "overview" | "details";
|
export type ReviewDetailPaneType = "overview" | "details";
|
||||||
|
|
||||||
|
export type ConsolidatedSegmentData = {
|
||||||
|
startTime: number;
|
||||||
|
endTime: number;
|
||||||
|
severity: ReviewSeverity | "empty";
|
||||||
|
reviewed: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TimelineZoomDirection = "in" | "out" | null;
|
||||||
|
@ -53,6 +53,7 @@ import { FilterList, LAST_24_HOURS_KEY } from "@/types/filter";
|
|||||||
import { GiSoundWaves } from "react-icons/gi";
|
import { GiSoundWaves } from "react-icons/gi";
|
||||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||||
import ReviewDetailDialog from "@/components/overlay/detail/ReviewDetailDialog";
|
import ReviewDetailDialog from "@/components/overlay/detail/ReviewDetailDialog";
|
||||||
|
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
|
||||||
|
|
||||||
type EventViewProps = {
|
type EventViewProps = {
|
||||||
reviewItems?: SegmentedReviewData;
|
reviewItems?: SegmentedReviewData;
|
||||||
@ -461,8 +462,6 @@ function DetectionReview({
|
|||||||
}: DetectionReviewProps) {
|
}: DetectionReviewProps) {
|
||||||
const reviewTimelineRef = useRef<HTMLDivElement>(null);
|
const reviewTimelineRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
const segmentDuration = 60;
|
|
||||||
|
|
||||||
// detail
|
// detail
|
||||||
|
|
||||||
const [reviewDetail, setReviewDetail] = useState<ReviewSegment>();
|
const [reviewDetail, setReviewDetail] = useState<ReviewSegment>();
|
||||||
@ -492,9 +491,38 @@ function DetectionReview({
|
|||||||
[timeRange],
|
[timeRange],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const [zoomSettings, setZoomSettings] = useState({
|
||||||
|
segmentDuration: 60,
|
||||||
|
timestampSpread: 15,
|
||||||
|
});
|
||||||
|
|
||||||
|
const possibleZoomLevels = useMemo(
|
||||||
|
() => [
|
||||||
|
{ segmentDuration: 60, timestampSpread: 15 },
|
||||||
|
{ segmentDuration: 30, timestampSpread: 5 },
|
||||||
|
{ segmentDuration: 10, timestampSpread: 1 },
|
||||||
|
],
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleZoomChange = useCallback(
|
||||||
|
(newZoomLevel: number) => {
|
||||||
|
setZoomSettings(possibleZoomLevels[newZoomLevel]);
|
||||||
|
},
|
||||||
|
[possibleZoomLevels],
|
||||||
|
);
|
||||||
|
|
||||||
|
const { isZooming, zoomDirection } = useTimelineZoom({
|
||||||
|
zoomSettings,
|
||||||
|
zoomLevels: possibleZoomLevels,
|
||||||
|
onZoomChange: handleZoomChange,
|
||||||
|
timelineRef: reviewTimelineRef,
|
||||||
|
timelineDuration,
|
||||||
|
});
|
||||||
|
|
||||||
const { alignStartDateToTimeline, getVisibleTimelineDuration } =
|
const { alignStartDateToTimeline, getVisibleTimelineDuration } =
|
||||||
useTimelineUtils({
|
useTimelineUtils({
|
||||||
segmentDuration,
|
segmentDuration: zoomSettings.segmentDuration,
|
||||||
timelineDuration,
|
timelineDuration,
|
||||||
timelineRef: reviewTimelineRef,
|
timelineRef: reviewTimelineRef,
|
||||||
});
|
});
|
||||||
@ -692,7 +720,7 @@ function DetectionReview({
|
|||||||
data-start={value.start_time}
|
data-start={value.start_time}
|
||||||
data-segment-start={
|
data-segment-start={
|
||||||
alignStartDateToTimeline(value.start_time) -
|
alignStartDateToTimeline(value.start_time) -
|
||||||
segmentDuration
|
zoomSettings.segmentDuration
|
||||||
}
|
}
|
||||||
className="review-item relative rounded-lg"
|
className="review-item relative rounded-lg"
|
||||||
>
|
>
|
||||||
@ -749,13 +777,13 @@ function DetectionReview({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-[65px] flex-row md:w-[110px]">
|
<div className="flex w-[65px] flex-row md:w-[110px]">
|
||||||
<div className="no-scrollbar w-[55px] overflow-y-auto md:w-[100px]">
|
<div className="no-scrollbar w-[55px] md:w-[100px]">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<Skeleton className="size-full" />
|
<Skeleton className="size-full" />
|
||||||
) : (
|
) : (
|
||||||
<EventReviewTimeline
|
<EventReviewTimeline
|
||||||
segmentDuration={segmentDuration}
|
segmentDuration={zoomSettings.segmentDuration}
|
||||||
timestampSpread={15}
|
timestampSpread={zoomSettings.timestampSpread}
|
||||||
timelineStart={timeRange.before}
|
timelineStart={timeRange.before}
|
||||||
timelineEnd={timeRange.after}
|
timelineEnd={timeRange.after}
|
||||||
showMinimap={showMinimap && !previewTime}
|
showMinimap={showMinimap && !previewTime}
|
||||||
@ -769,6 +797,8 @@ function DetectionReview({
|
|||||||
contentRef={contentRef}
|
contentRef={contentRef}
|
||||||
timelineRef={reviewTimelineRef}
|
timelineRef={reviewTimelineRef}
|
||||||
dense={isMobile}
|
dense={isMobile}
|
||||||
|
isZooming={isZooming}
|
||||||
|
zoomDirection={zoomDirection}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -780,7 +810,7 @@ function DetectionReview({
|
|||||||
reviewTimelineRef={reviewTimelineRef}
|
reviewTimelineRef={reviewTimelineRef}
|
||||||
timelineStart={timeRange.before}
|
timelineStart={timeRange.before}
|
||||||
timelineEnd={timeRange.after}
|
timelineEnd={timeRange.after}
|
||||||
segmentDuration={segmentDuration}
|
segmentDuration={zoomSettings.segmentDuration}
|
||||||
events={reviewItems?.all ?? []}
|
events={reviewItems?.all ?? []}
|
||||||
severityType={severity}
|
severityType={severity}
|
||||||
/>
|
/>
|
||||||
@ -1095,7 +1125,6 @@ function MotionReview({
|
|||||||
setHandlebarTime={setCurrentTime}
|
setHandlebarTime={setCurrentTime}
|
||||||
events={reviewItems?.all ?? []}
|
events={reviewItems?.all ?? []}
|
||||||
motion_events={motionData ?? []}
|
motion_events={motionData ?? []}
|
||||||
severityType="significant_motion"
|
|
||||||
contentRef={contentRef}
|
contentRef={contentRef}
|
||||||
onHandlebarDraggingChange={(scrubbing) => {
|
onHandlebarDraggingChange={(scrubbing) => {
|
||||||
if (playing && scrubbing) {
|
if (playing && scrubbing) {
|
||||||
@ -1105,6 +1134,8 @@ function MotionReview({
|
|||||||
setScrubbing(scrubbing);
|
setScrubbing(scrubbing);
|
||||||
}}
|
}}
|
||||||
dense={isMobileOnly}
|
dense={isMobileOnly}
|
||||||
|
isZooming={false}
|
||||||
|
zoomDirection={null}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Skeleton className="size-full" />
|
<Skeleton className="size-full" />
|
||||||
|
@ -46,8 +46,7 @@ import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
|
|||||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import { useFullscreen } from "@/hooks/use-fullscreen";
|
import { useFullscreen } from "@/hooks/use-fullscreen";
|
||||||
|
import { useTimelineZoom } from "@/hooks/use-timeline-zoom";
|
||||||
const SEGMENT_DURATION = 30;
|
|
||||||
|
|
||||||
type RecordingViewProps = {
|
type RecordingViewProps = {
|
||||||
startCamera: string;
|
startCamera: string;
|
||||||
@ -633,6 +632,7 @@ export function RecordingView({
|
|||||||
|
|
||||||
type TimelineProps = {
|
type TimelineProps = {
|
||||||
contentRef: MutableRefObject<HTMLDivElement | null>;
|
contentRef: MutableRefObject<HTMLDivElement | null>;
|
||||||
|
timelineRef?: MutableRefObject<HTMLDivElement | null>;
|
||||||
mainCamera: string;
|
mainCamera: string;
|
||||||
timelineType: TimelineType;
|
timelineType: TimelineType;
|
||||||
timeRange: TimeRange;
|
timeRange: TimeRange;
|
||||||
@ -646,6 +646,7 @@ type TimelineProps = {
|
|||||||
};
|
};
|
||||||
function Timeline({
|
function Timeline({
|
||||||
contentRef,
|
contentRef,
|
||||||
|
timelineRef,
|
||||||
mainCamera,
|
mainCamera,
|
||||||
timelineType,
|
timelineType,
|
||||||
timeRange,
|
timeRange,
|
||||||
@ -657,12 +658,48 @@ function Timeline({
|
|||||||
setScrubbing,
|
setScrubbing,
|
||||||
setExportRange,
|
setExportRange,
|
||||||
}: TimelineProps) {
|
}: TimelineProps) {
|
||||||
const { data: motionData } = useSWR<MotionData[]>([
|
const internalTimelineRef = useRef<HTMLDivElement>(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<MotionData[]>([
|
||||||
"review/activity/motion",
|
"review/activity/motion",
|
||||||
{
|
{
|
||||||
before: timeRange.before,
|
before: timeRange.before,
|
||||||
after: timeRange.after,
|
after: timeRange.after,
|
||||||
scale: SEGMENT_DURATION / 2,
|
scale: Math.round(zoomSettings.segmentDuration / 2),
|
||||||
cameras: mainCamera,
|
cameras: mainCamera,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@ -695,10 +732,11 @@ function Timeline({
|
|||||||
<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>
|
<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>
|
||||||
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 h-[30px] w-full bg-gradient-to-t from-secondary to-transparent"></div>
|
<div className="pointer-events-none absolute inset-x-0 bottom-0 z-20 h-[30px] w-full bg-gradient-to-t from-secondary to-transparent"></div>
|
||||||
{timelineType == "timeline" ? (
|
{timelineType == "timeline" ? (
|
||||||
motionData ? (
|
!isLoading ? (
|
||||||
<MotionReviewTimeline
|
<MotionReviewTimeline
|
||||||
segmentDuration={30}
|
timelineRef={selectedTimelineRef}
|
||||||
timestampSpread={15}
|
segmentDuration={zoomSettings.segmentDuration}
|
||||||
|
timestampSpread={zoomSettings.timestampSpread}
|
||||||
timelineStart={timeRange.before}
|
timelineStart={timeRange.before}
|
||||||
timelineEnd={timeRange.after}
|
timelineEnd={timeRange.after}
|
||||||
showHandlebar={exportRange == undefined}
|
showHandlebar={exportRange == undefined}
|
||||||
@ -711,9 +749,10 @@ function Timeline({
|
|||||||
setHandlebarTime={setCurrentTime}
|
setHandlebarTime={setCurrentTime}
|
||||||
events={mainCameraReviewItems}
|
events={mainCameraReviewItems}
|
||||||
motion_events={motionData ?? []}
|
motion_events={motionData ?? []}
|
||||||
severityType="significant_motion"
|
|
||||||
contentRef={contentRef}
|
contentRef={contentRef}
|
||||||
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
|
onHandlebarDraggingChange={(scrubbing) => setScrubbing(scrubbing)}
|
||||||
|
isZooming={isZooming}
|
||||||
|
zoomDirection={zoomDirection}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Skeleton className="size-full" />
|
<Skeleton className="size-full" />
|
||||||
|
@ -32,6 +32,8 @@ module.exports = {
|
|||||||
scale2: "scale2 3s ease-in-out infinite",
|
scale2: "scale2 3s ease-in-out infinite",
|
||||||
scale3: "scale3 3s ease-in-out infinite",
|
scale3: "scale3 3s ease-in-out infinite",
|
||||||
scale4: "scale4 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: {
|
aspectRatio: {
|
||||||
wide: "32 / 9",
|
wide: "32 / 9",
|
||||||
@ -140,6 +142,16 @@ module.exports = {
|
|||||||
"40%, 60%": { transform: "scale(1.4)" },
|
"40%, 60%": { transform: "scale(1.4)" },
|
||||||
"30%, 70%": { transform: "scale(1)" },
|
"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: {
|
screens: {
|
||||||
xs: "480px",
|
xs: "480px",
|
||||||
|
Loading…
Reference in New Issue
Block a user