mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
Summary timeline (#10569)
* implement summary timeline * implement summary timeline * merge dev * conditionally attach listeners only when dragging * set up listeners with a ref
This commit is contained in:
parent
c8fd23caa1
commit
f113acee33
@ -43,7 +43,7 @@ export default function NewReviewData({
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<div className="flex justify-center items-center md:mr-[100px]">
|
||||
<div className="flex justify-center items-center md:mr-[115px] pointer-events-auto">
|
||||
<Button
|
||||
className={`${
|
||||
hasUpdate
|
||||
|
@ -30,6 +30,7 @@ export type EventReviewTimelineProps = {
|
||||
setExportEndTime?: React.Dispatch<React.SetStateAction<number>>;
|
||||
events: ReviewSegment[];
|
||||
severityType: ReviewSeverity;
|
||||
timelineRef?: RefObject<HTMLDivElement>;
|
||||
contentRef: RefObject<HTMLDivElement>;
|
||||
onHandlebarDraggingChange?: (isDragging: boolean) => void;
|
||||
};
|
||||
@ -52,6 +53,7 @@ export function EventReviewTimeline({
|
||||
setExportEndTime,
|
||||
events,
|
||||
severityType,
|
||||
timelineRef,
|
||||
contentRef,
|
||||
onHandlebarDraggingChange,
|
||||
}: EventReviewTimelineProps) {
|
||||
@ -59,7 +61,7 @@ export function EventReviewTimeline({
|
||||
const [exportStartPosition, setExportStartPosition] = useState(0);
|
||||
const [exportEndPosition, setExportEndPosition] = useState(0);
|
||||
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const internalTimelineRef = useRef<HTMLDivElement>(null);
|
||||
const handlebarRef = useRef<HTMLDivElement>(null);
|
||||
const handlebarTimeRef = useRef<HTMLDivElement>(null);
|
||||
const exportStartRef = useRef<HTMLDivElement>(null);
|
||||
@ -98,7 +100,7 @@ export function EventReviewTimeline({
|
||||
handleMouseMove: handlebarMouseMove,
|
||||
} = useDraggableElement({
|
||||
contentRef,
|
||||
timelineRef,
|
||||
timelineRef: timelineRef || internalTimelineRef,
|
||||
draggableElementRef: handlebarRef,
|
||||
segmentDuration,
|
||||
showDraggableElement: showHandlebar,
|
||||
@ -117,7 +119,7 @@ export function EventReviewTimeline({
|
||||
handleMouseMove: exportStartMouseMove,
|
||||
} = useDraggableElement({
|
||||
contentRef,
|
||||
timelineRef,
|
||||
timelineRef: timelineRef || internalTimelineRef,
|
||||
draggableElementRef: exportStartRef,
|
||||
segmentDuration,
|
||||
showDraggableElement: showExportHandles,
|
||||
@ -138,7 +140,7 @@ export function EventReviewTimeline({
|
||||
handleMouseMove: exportEndMouseMove,
|
||||
} = useDraggableElement({
|
||||
contentRef,
|
||||
timelineRef,
|
||||
timelineRef: timelineRef || internalTimelineRef,
|
||||
draggableElementRef: exportEndRef,
|
||||
segmentDuration,
|
||||
showDraggableElement: showExportHandles,
|
||||
@ -213,7 +215,7 @@ export function EventReviewTimeline({
|
||||
|
||||
return (
|
||||
<ReviewTimeline
|
||||
timelineRef={timelineRef}
|
||||
timelineRef={timelineRef || internalTimelineRef}
|
||||
handlebarRef={handlebarRef}
|
||||
handlebarTimeRef={handlebarTimeRef}
|
||||
handlebarMouseMove={handlebarMouseMove}
|
||||
|
@ -68,12 +68,7 @@ export function EventSegment({
|
||||
[getReviewed, segmentTime],
|
||||
);
|
||||
|
||||
const {
|
||||
roundTopPrimary,
|
||||
roundBottomPrimary,
|
||||
roundTopSecondary,
|
||||
roundBottomSecondary,
|
||||
} = useMemo(
|
||||
const { roundTopPrimary, roundBottomPrimary } = useMemo(
|
||||
() => shouldShowRoundedCorners(segmentTime),
|
||||
[shouldShowRoundedCorners, segmentTime],
|
||||
);
|
||||
@ -253,20 +248,6 @@ export function EventSegment({
|
||||
</div>
|
||||
</HoverCard>
|
||||
)}
|
||||
|
||||
{severityValue !== displaySeverityType && (
|
||||
<div className="absolute right-0 h-2 z-10">
|
||||
<div
|
||||
key={`${segmentKey}_${index}_secondary_data`}
|
||||
className={`
|
||||
w-1 h-2 bg-gradient-to-r
|
||||
${roundBottomSecondary ? "rounded-bl-full rounded-br-full" : ""}
|
||||
${roundTopSecondary ? "rounded-tl-full rounded-tr-full" : ""}
|
||||
${severityColors[severityValue]}
|
||||
`}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
|
@ -32,6 +32,7 @@ export type MotionReviewTimelineProps = {
|
||||
motion_events: MotionData[];
|
||||
severityType: ReviewSeverity;
|
||||
contentRef: RefObject<HTMLDivElement>;
|
||||
timelineRef?: RefObject<HTMLDivElement>;
|
||||
onHandlebarDraggingChange?: (isDragging: boolean) => void;
|
||||
};
|
||||
|
||||
@ -54,13 +55,14 @@ export function MotionReviewTimeline({
|
||||
events,
|
||||
motion_events,
|
||||
contentRef,
|
||||
timelineRef,
|
||||
onHandlebarDraggingChange,
|
||||
}: MotionReviewTimelineProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [exportStartPosition, setExportStartPosition] = useState(0);
|
||||
const [exportEndPosition, setExportEndPosition] = useState(0);
|
||||
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const internalTimelineRef = useRef<HTMLDivElement>(null);
|
||||
const handlebarRef = useRef<HTMLDivElement>(null);
|
||||
const handlebarTimeRef = useRef<HTMLDivElement>(null);
|
||||
const exportStartRef = useRef<HTMLDivElement>(null);
|
||||
@ -99,7 +101,7 @@ export function MotionReviewTimeline({
|
||||
handleMouseMove: handlebarMouseMove,
|
||||
} = useDraggableElement({
|
||||
contentRef,
|
||||
timelineRef,
|
||||
timelineRef: timelineRef || internalTimelineRef,
|
||||
draggableElementRef: handlebarRef,
|
||||
segmentDuration,
|
||||
showDraggableElement: showHandlebar,
|
||||
@ -118,7 +120,7 @@ export function MotionReviewTimeline({
|
||||
handleMouseMove: exportStartMouseMove,
|
||||
} = useDraggableElement({
|
||||
contentRef,
|
||||
timelineRef,
|
||||
timelineRef: timelineRef || internalTimelineRef,
|
||||
draggableElementRef: exportStartRef,
|
||||
segmentDuration,
|
||||
showDraggableElement: showExportHandles,
|
||||
@ -139,7 +141,7 @@ export function MotionReviewTimeline({
|
||||
handleMouseMove: exportEndMouseMove,
|
||||
} = useDraggableElement({
|
||||
contentRef,
|
||||
timelineRef,
|
||||
timelineRef: timelineRef || internalTimelineRef,
|
||||
draggableElementRef: exportEndRef,
|
||||
segmentDuration,
|
||||
showDraggableElement: showExportHandles,
|
||||
@ -213,9 +215,46 @@ export function MotionReviewTimeline({
|
||||
}
|
||||
}, [isDragging, onHandlebarDraggingChange]);
|
||||
|
||||
const segmentsObserver = useRef<IntersectionObserver | null>(null);
|
||||
const selectedTimelineRef = timelineRef || internalTimelineRef;
|
||||
useEffect(() => {
|
||||
if (selectedTimelineRef.current && segments) {
|
||||
segmentsObserver.current = new IntersectionObserver(
|
||||
(entries) => {
|
||||
entries.forEach((entry) => {
|
||||
if (entry.isIntersecting) {
|
||||
const segmentId = entry.target.getAttribute("data-segment-id");
|
||||
|
||||
const segmentElements =
|
||||
internalTimelineRef.current?.querySelectorAll(
|
||||
`[data-segment-id="${segmentId}"] .motion-segment`,
|
||||
);
|
||||
segmentElements?.forEach((segmentElement) => {
|
||||
segmentElement.classList.remove("hidden");
|
||||
segmentElement.classList.add("animate-in");
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
{ threshold: 0 },
|
||||
);
|
||||
|
||||
// Get all segment divs and observe each one
|
||||
const segmentDivs =
|
||||
selectedTimelineRef.current.querySelectorAll(".segment.has-data");
|
||||
segmentDivs.forEach((segmentDiv) => {
|
||||
segmentsObserver.current?.observe(segmentDiv);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
segmentsObserver.current?.disconnect();
|
||||
};
|
||||
}, [selectedTimelineRef, segments]);
|
||||
|
||||
return (
|
||||
<ReviewTimeline
|
||||
timelineRef={timelineRef}
|
||||
timelineRef={timelineRef || internalTimelineRef}
|
||||
handlebarRef={handlebarRef}
|
||||
handlebarTimeRef={handlebarTimeRef}
|
||||
handlebarMouseMove={handlebarMouseMove}
|
||||
|
@ -176,7 +176,8 @@ export function MotionSegment({
|
||||
return (
|
||||
<div
|
||||
key={segmentKey}
|
||||
className={segmentClasses}
|
||||
data-segment-id={segmentKey}
|
||||
className={`segment ${firstHalfSegmentWidth > 1 || secondHalfSegmentWidth > 1 ? "has-data" : ""} ${segmentClasses}`}
|
||||
onClick={segmentClick}
|
||||
onTouchEnd={(event) => handleTouchStart(event, segmentClick)}
|
||||
>
|
||||
@ -203,7 +204,7 @@ export function MotionSegment({
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
key={`${segmentKey}_motion_data_1`}
|
||||
className={`h-[2px] rounded-full ${severity[0] != 0 ? "bg-motion_review-dimmed" : "bg-motion_review"}`}
|
||||
className={`motion-segment ${secondHalfSegmentWidth > 1 ? "hidden" : ""} zoom-in-[0.2] ${secondHalfSegmentWidth < 5 ? "duration-200" : "duration-1000"} h-[2px] rounded-full ${severity[0] != 0 ? "bg-motion_review-dimmed" : "bg-motion_review"}`}
|
||||
style={{
|
||||
width: secondHalfSegmentWidth,
|
||||
}}
|
||||
@ -215,7 +216,7 @@ export function MotionSegment({
|
||||
<div className="flex justify-center">
|
||||
<div
|
||||
key={`${segmentKey}_motion_data_2`}
|
||||
className={`h-[2px] rounded-full ${severity[0] != 0 ? "bg-motion_review-dimmed" : "bg-motion_review"}`}
|
||||
className={`motion-segment ${firstHalfSegmentWidth > 1 ? "hidden" : ""} zoom-in-[0.2] ${firstHalfSegmentWidth < 5 ? "duration-200" : "duration-1000"} h-[2px] rounded-full ${severity[0] != 0 ? "bg-motion_review-dimmed" : "bg-motion_review"}`}
|
||||
style={{
|
||||
width: firstHalfSegmentWidth,
|
||||
}}
|
||||
|
@ -234,7 +234,7 @@ export function ReviewTimeline({
|
||||
onTouchMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onTouchEnd={handleMouseUp}
|
||||
className={`relative h-full overflow-y-auto no-scrollbar bg-secondary ${
|
||||
className={`relative h-full overflow-y-auto no-scrollbar select-none bg-secondary ${
|
||||
isDragging && (showHandlebar || showExportHandles)
|
||||
? "cursor-grabbing"
|
||||
: "cursor-auto"
|
||||
|
67
web/src/components/timeline/SummarySegment.tsx
Normal file
67
web/src/components/timeline/SummarySegment.tsx
Normal file
@ -0,0 +1,67 @@
|
||||
import { useEventSegmentUtils } from "@/hooks/use-event-segment-utils";
|
||||
import { ReviewSegment } from "@/types/review";
|
||||
import React, { useMemo } from "react";
|
||||
// import useTapUtils from "@/hooks/use-tap-utils";
|
||||
|
||||
type SummarySegmentProps = {
|
||||
events: ReviewSegment[];
|
||||
segmentTime: number;
|
||||
segmentDuration: number;
|
||||
segmentHeight: number;
|
||||
};
|
||||
|
||||
export function SummarySegment({
|
||||
events,
|
||||
segmentTime,
|
||||
segmentDuration,
|
||||
segmentHeight,
|
||||
}: SummarySegmentProps) {
|
||||
const severityType = "all";
|
||||
const { getSeverity, getReviewed, displaySeverityType } =
|
||||
useEventSegmentUtils(segmentDuration, events, severityType);
|
||||
|
||||
const severity = useMemo(
|
||||
() => getSeverity(segmentTime, displaySeverityType),
|
||||
[getSeverity, segmentTime, displaySeverityType],
|
||||
);
|
||||
|
||||
const reviewed = useMemo(
|
||||
() => getReviewed(segmentTime),
|
||||
[getReviewed, segmentTime],
|
||||
);
|
||||
|
||||
const segmentKey = useMemo(() => segmentTime, [segmentTime]);
|
||||
|
||||
const severityColors: { [key: number]: string } = {
|
||||
1: reviewed ? "bg-severity_motion/50" : "bg-severity_motion",
|
||||
2: reviewed ? "bg-severity_detection/50" : "bg-severity_detection",
|
||||
3: reviewed ? "bg-severity_alert/50" : "bg-severity_alert",
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
key={segmentKey}
|
||||
className="relative w-full"
|
||||
style={{ height: segmentHeight }}
|
||||
>
|
||||
{severity.map((severityValue: number, index: number) => {
|
||||
return (
|
||||
<React.Fragment key={index}>
|
||||
<div
|
||||
className="flex justify-end cursor-pointer"
|
||||
style={{ height: segmentHeight }}
|
||||
>
|
||||
<div
|
||||
key={`${segmentKey}_${index}_secondary_data`}
|
||||
style={{ height: segmentHeight }}
|
||||
className={`w-[10px] ${severityColors[severityValue]}`}
|
||||
></div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SummarySegment;
|
317
web/src/components/timeline/SummaryTimeline.tsx
Normal file
317
web/src/components/timeline/SummaryTimeline.tsx
Normal file
@ -0,0 +1,317 @@
|
||||
import {
|
||||
RefObject,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { SummarySegment } from "./SummarySegment";
|
||||
import { useTimelineUtils } from "@/hooks/use-timeline-utils";
|
||||
import { ReviewSegment } from "@/types/review";
|
||||
import { isMobile } from "react-device-detect";
|
||||
|
||||
export type SummaryTimelineProps = {
|
||||
reviewTimelineRef: RefObject<HTMLDivElement>;
|
||||
timelineStart: number;
|
||||
timelineEnd: number;
|
||||
segmentDuration: number;
|
||||
events: ReviewSegment[];
|
||||
};
|
||||
|
||||
export function SummaryTimeline({
|
||||
reviewTimelineRef,
|
||||
timelineStart,
|
||||
timelineEnd,
|
||||
segmentDuration,
|
||||
events,
|
||||
}: SummaryTimelineProps) {
|
||||
const summaryTimelineRef = useRef<HTMLDivElement>(null);
|
||||
const visibleSectionRef = useRef<HTMLDivElement>(null);
|
||||
const [segmentHeight, setSegmentHeight] = useState(0);
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [scrollStartPosition, setScrollStartPosition] = useState<number>(0);
|
||||
const [initialReviewTimelineScrollTop, setInitialReviewTimelineScrollTop] =
|
||||
useState<number>(0);
|
||||
|
||||
const { alignStartDateToTimeline } = useTimelineUtils(segmentDuration);
|
||||
|
||||
const timelineStartAligned = useMemo(
|
||||
() => alignStartDateToTimeline(timelineStart) + 2 * segmentDuration,
|
||||
[timelineStart, alignStartDateToTimeline, segmentDuration],
|
||||
);
|
||||
|
||||
const reviewTimelineDuration = useMemo(
|
||||
() => timelineStart - timelineEnd + 4 * segmentDuration,
|
||||
[timelineEnd, timelineStart, segmentDuration],
|
||||
);
|
||||
|
||||
// Generate segments for the timeline
|
||||
const generateSegments = useCallback(() => {
|
||||
const segmentCount = reviewTimelineDuration / segmentDuration;
|
||||
|
||||
if (segmentHeight) {
|
||||
return Array.from({ length: segmentCount }, (_, index) => {
|
||||
const segmentTime = timelineStartAligned - index * segmentDuration;
|
||||
|
||||
return (
|
||||
<SummarySegment
|
||||
key={segmentTime}
|
||||
events={events}
|
||||
segmentDuration={segmentDuration}
|
||||
segmentTime={segmentTime}
|
||||
segmentHeight={segmentHeight}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}
|
||||
}, [
|
||||
segmentDuration,
|
||||
timelineStartAligned,
|
||||
events,
|
||||
reviewTimelineDuration,
|
||||
segmentHeight,
|
||||
]);
|
||||
|
||||
const segments = useMemo(
|
||||
() => generateSegments(),
|
||||
// we know that these deps are correct
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[
|
||||
segmentDuration,
|
||||
segmentHeight,
|
||||
timelineStartAligned,
|
||||
events,
|
||||
reviewTimelineDuration,
|
||||
segmentHeight,
|
||||
generateSegments,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (reviewTimelineRef.current && summaryTimelineRef.current) {
|
||||
const content = reviewTimelineRef.current;
|
||||
const summary = summaryTimelineRef.current;
|
||||
|
||||
const handleScroll = () => {
|
||||
const {
|
||||
clientHeight: reviewTimelineVisibleHeight,
|
||||
scrollHeight: reviewTimelineFullHeight,
|
||||
scrollTop: scrolled,
|
||||
} = content;
|
||||
const { clientHeight: summaryTimelineVisibleHeight } = summary;
|
||||
|
||||
if (visibleSectionRef.current) {
|
||||
visibleSectionRef.current.style.top = `${summaryTimelineVisibleHeight * (scrolled / reviewTimelineFullHeight)}px`;
|
||||
visibleSectionRef.current.style.height = `${reviewTimelineVisibleHeight * (reviewTimelineVisibleHeight / reviewTimelineFullHeight)}px`;
|
||||
}
|
||||
};
|
||||
|
||||
content.addEventListener("scroll", handleScroll);
|
||||
return () => {
|
||||
content.removeEventListener("scroll", handleScroll);
|
||||
};
|
||||
}
|
||||
}, [reviewTimelineRef, summaryTimelineRef]);
|
||||
|
||||
useEffect(() => {
|
||||
if (summaryTimelineRef.current) {
|
||||
const { clientHeight: summaryTimelineVisibleHeight } =
|
||||
summaryTimelineRef.current;
|
||||
|
||||
setSegmentHeight(
|
||||
summaryTimelineVisibleHeight /
|
||||
(reviewTimelineDuration / segmentDuration),
|
||||
);
|
||||
}
|
||||
}, [reviewTimelineDuration, summaryTimelineRef, segmentDuration]);
|
||||
|
||||
const timelineClick = useCallback(
|
||||
(
|
||||
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,
|
||||
) => {
|
||||
// prevent default only for mouse events
|
||||
// to avoid chrome/android issues
|
||||
if (e.nativeEvent instanceof MouseEvent) {
|
||||
e.preventDefault();
|
||||
}
|
||||
e.stopPropagation();
|
||||
|
||||
let clientY;
|
||||
if (isMobile && e.nativeEvent instanceof TouchEvent) {
|
||||
clientY = e.nativeEvent.touches[0].clientY;
|
||||
} else if (e.nativeEvent instanceof MouseEvent) {
|
||||
clientY = e.nativeEvent.clientY;
|
||||
}
|
||||
if (
|
||||
clientY &&
|
||||
reviewTimelineRef.current &&
|
||||
summaryTimelineRef.current &&
|
||||
visibleSectionRef.current
|
||||
) {
|
||||
const { clientHeight: summaryTimelineVisibleHeight } =
|
||||
summaryTimelineRef.current;
|
||||
|
||||
const rect = summaryTimelineRef.current.getBoundingClientRect();
|
||||
const summaryTimelineTop = rect.top;
|
||||
|
||||
const { scrollHeight: reviewTimelineHeight } =
|
||||
reviewTimelineRef.current;
|
||||
|
||||
const { clientHeight: visibleSectionHeight } =
|
||||
visibleSectionRef.current;
|
||||
|
||||
const visibleSectionOffset = -(visibleSectionHeight / 2);
|
||||
|
||||
const clickPercentage =
|
||||
(clientY - summaryTimelineTop + visibleSectionOffset) /
|
||||
summaryTimelineVisibleHeight;
|
||||
|
||||
reviewTimelineRef.current.scrollTo({
|
||||
top: Math.floor(reviewTimelineHeight * clickPercentage),
|
||||
behavior: "smooth",
|
||||
});
|
||||
}
|
||||
},
|
||||
[reviewTimelineRef, summaryTimelineRef, visibleSectionRef],
|
||||
);
|
||||
|
||||
const handleMouseDown = useCallback(
|
||||
(
|
||||
e: React.MouseEvent<HTMLDivElement> | React.TouchEvent<HTMLDivElement>,
|
||||
) => {
|
||||
// prevent default only for mouse events
|
||||
// to avoid chrome/android issues
|
||||
if (e.nativeEvent instanceof MouseEvent) {
|
||||
e.preventDefault();
|
||||
}
|
||||
e.stopPropagation();
|
||||
setIsDragging(true);
|
||||
|
||||
let clientY;
|
||||
if (isMobile && e.nativeEvent instanceof TouchEvent) {
|
||||
clientY = e.nativeEvent.touches[0].clientY;
|
||||
} else if (e.nativeEvent instanceof MouseEvent) {
|
||||
clientY = e.nativeEvent.clientY;
|
||||
}
|
||||
if (clientY && summaryTimelineRef.current && reviewTimelineRef.current) {
|
||||
setScrollStartPosition(clientY);
|
||||
setInitialReviewTimelineScrollTop(reviewTimelineRef.current.scrollTop);
|
||||
}
|
||||
},
|
||||
[setIsDragging, summaryTimelineRef, reviewTimelineRef],
|
||||
);
|
||||
|
||||
const handleMouseUp = useCallback(
|
||||
(e: MouseEvent | TouchEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (isDragging) {
|
||||
setIsDragging(false);
|
||||
}
|
||||
},
|
||||
[isDragging, setIsDragging],
|
||||
);
|
||||
|
||||
const handleMouseMove = useCallback(
|
||||
(e: MouseEvent | TouchEvent) => {
|
||||
if (
|
||||
summaryTimelineRef.current &&
|
||||
reviewTimelineRef.current &&
|
||||
visibleSectionRef.current
|
||||
) {
|
||||
// prevent default only for mouse events
|
||||
// to avoid chrome/android issues
|
||||
if (e instanceof MouseEvent) {
|
||||
e.preventDefault();
|
||||
}
|
||||
e.stopPropagation();
|
||||
let clientY;
|
||||
if (isMobile && e instanceof TouchEvent) {
|
||||
clientY = e.touches[0].clientY;
|
||||
} else if (e instanceof MouseEvent) {
|
||||
clientY = e.clientY;
|
||||
}
|
||||
if (isDragging && clientY) {
|
||||
const { clientHeight: summaryTimelineVisibleHeight } =
|
||||
summaryTimelineRef.current;
|
||||
|
||||
const {
|
||||
scrollHeight: reviewTimelineHeight,
|
||||
clientHeight: reviewTimelineVisibleHeight,
|
||||
} = reviewTimelineRef.current;
|
||||
|
||||
const { clientHeight: visibleSectionHeight } =
|
||||
visibleSectionRef.current;
|
||||
|
||||
const deltaY =
|
||||
(clientY - scrollStartPosition) *
|
||||
(summaryTimelineVisibleHeight / visibleSectionHeight);
|
||||
|
||||
const newScrollTop = Math.min(
|
||||
initialReviewTimelineScrollTop + deltaY,
|
||||
reviewTimelineHeight - reviewTimelineVisibleHeight,
|
||||
);
|
||||
|
||||
reviewTimelineRef.current.scrollTop = newScrollTop;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
initialReviewTimelineScrollTop,
|
||||
isDragging,
|
||||
reviewTimelineRef,
|
||||
scrollStartPosition,
|
||||
],
|
||||
);
|
||||
|
||||
const documentRef = useRef<Document | null>(document);
|
||||
useEffect(() => {
|
||||
const documentInstance = documentRef.current;
|
||||
|
||||
if (isDragging) {
|
||||
documentInstance?.addEventListener("mousemove", handleMouseMove);
|
||||
documentInstance?.addEventListener("touchmove", handleMouseMove);
|
||||
documentInstance?.addEventListener("mouseup", handleMouseUp);
|
||||
documentInstance?.addEventListener("touchend", handleMouseUp);
|
||||
} else {
|
||||
documentInstance?.removeEventListener("mousemove", handleMouseMove);
|
||||
documentInstance?.removeEventListener("touchmove", handleMouseMove);
|
||||
documentInstance?.removeEventListener("mouseup", handleMouseUp);
|
||||
documentInstance?.removeEventListener("touchend", handleMouseUp);
|
||||
}
|
||||
return () => {
|
||||
documentInstance?.removeEventListener("mousemove", handleMouseMove);
|
||||
documentInstance?.removeEventListener("touchmove", handleMouseMove);
|
||||
documentInstance?.removeEventListener("mouseup", handleMouseUp);
|
||||
documentInstance?.removeEventListener("touchend", handleMouseUp);
|
||||
};
|
||||
}, [handleMouseMove, handleMouseUp, isDragging]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`relative h-full overflow-hidden no-scrollbar select-none bg-secondary border-l-[1px] border-neutral-700`}
|
||||
role="scrollbar"
|
||||
>
|
||||
<div
|
||||
ref={summaryTimelineRef}
|
||||
className="h-full flex flex-col relative z-10"
|
||||
onClick={timelineClick}
|
||||
onTouchEnd={timelineClick}
|
||||
>
|
||||
{segments}
|
||||
</div>
|
||||
<div
|
||||
ref={visibleSectionRef}
|
||||
onMouseDown={handleMouseDown}
|
||||
onTouchStart={handleMouseDown}
|
||||
className={`bg-primary-foreground/30 z-20 absolute w-full touch-none ${
|
||||
isDragging ? "cursor-grabbing" : "cursor-grab"
|
||||
}`}
|
||||
></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SummaryTimeline;
|
@ -32,7 +32,7 @@ export function MinimapBounds({
|
||||
<>
|
||||
{isFirstSegmentInMinimap && (
|
||||
<div
|
||||
className="absolute inset-0 -bottom-7 w-full flex items-center justify-center text-primary-foreground font-medium z-20 text-center text-[10px] scroll-mt-8 pointer-events-none"
|
||||
className="absolute inset-0 -bottom-7 w-full flex items-center justify-center text-primary-foreground font-medium z-20 text-center text-[10px] scroll-mt-8 pointer-events-none select-none"
|
||||
ref={firstMinimapSegmentRef}
|
||||
>
|
||||
{new Date(alignedMinimapStartTime * 1000).toLocaleTimeString([], {
|
||||
@ -44,7 +44,7 @@ export function MinimapBounds({
|
||||
)}
|
||||
|
||||
{isLastSegmentInMinimap && (
|
||||
<div className="absolute inset-0 -top-3 w-full flex items-center justify-center text-primary-foreground font-medium z-20 text-center text-[10px] pointer-events-none">
|
||||
<div className="absolute inset-0 -top-3 w-full flex items-center justify-center text-primary-foreground font-medium z-20 text-center text-[10px] pointer-events-none select-none">
|
||||
{new Date(alignedMinimapEndTime * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
@ -61,7 +61,7 @@ export function Tick({ timestamp, timestampSpread }: TickSegmentProps) {
|
||||
<div className="absolute">
|
||||
<div className="flex items-end content-end w-[12px] h-2">
|
||||
<div
|
||||
className={`pointer-events-none h-0.5 ${
|
||||
className={`pointer-events-none select-none h-0.5 ${
|
||||
timestamp.getMinutes() % timestampSpread === 0 &&
|
||||
timestamp.getSeconds() === 0
|
||||
? "w-[12px] bg-neutral-600 dark:bg-neutral-500"
|
||||
@ -88,7 +88,7 @@ export function Timestamp({
|
||||
{!isFirstSegmentInMinimap && !isLastSegmentInMinimap && (
|
||||
<div
|
||||
key={`${segmentKey}_timestamp`}
|
||||
className="pointer-events-none text-[8px] text-neutral-600 dark:text-neutral-500"
|
||||
className="pointer-events-none select-none text-[8px] text-neutral-600 dark:text-neutral-500"
|
||||
>
|
||||
{timestamp.getMinutes() % timestampSpread === 0 &&
|
||||
timestamp.getSeconds() === 0 &&
|
||||
|
@ -40,7 +40,8 @@ function useDraggableElement({
|
||||
}: DraggableElementProps) {
|
||||
const [clientYPosition, setClientYPosition] = useState<number | null>(null);
|
||||
const [initialClickAdjustment, setInitialClickAdjustment] = useState(0);
|
||||
const { alignStartDateToTimeline } = useTimelineUtils(segmentDuration);
|
||||
const { alignStartDateToTimeline, getCumulativeScrollTop } =
|
||||
useTimelineUtils(segmentDuration);
|
||||
|
||||
const draggingAtTopEdge = useMemo(() => {
|
||||
if (clientYPosition && timelineRef.current) {
|
||||
@ -125,15 +126,6 @@ function useDraggableElement({
|
||||
[isDragging, setIsDragging],
|
||||
);
|
||||
|
||||
const getCumulativeScrollTop = useCallback((element: HTMLElement | null) => {
|
||||
let scrollTop = 0;
|
||||
while (element) {
|
||||
scrollTop += element.scrollTop;
|
||||
element = element.parentElement;
|
||||
}
|
||||
return scrollTop;
|
||||
}, []);
|
||||
|
||||
const timestampToPixels = useCallback(
|
||||
(time: number) => {
|
||||
const { scrollHeight: timelineHeight } =
|
||||
|
@ -58,11 +58,12 @@ export const useEventSegmentUtils = (
|
||||
);
|
||||
const highestSeverityValue = Math.max(...severityValues);
|
||||
|
||||
if (
|
||||
severityValues.includes(displaySeverityType) &&
|
||||
displaySeverityType !== highestSeverityValue
|
||||
) {
|
||||
return [displaySeverityType, highestSeverityValue];
|
||||
if (severityValues.includes(displaySeverityType)) {
|
||||
const otherSeverityValues = severityValues.filter(
|
||||
(severity) => severity !== displaySeverityType,
|
||||
);
|
||||
const highestOtherSeverityValue = Math.max(...otherSeverityValues);
|
||||
return [displaySeverityType, highestOtherSeverityValue];
|
||||
} else {
|
||||
return [highestSeverityValue];
|
||||
}
|
||||
|
@ -19,8 +19,18 @@ export const useTimelineUtils = (segmentDuration: number) => {
|
||||
[segmentDuration],
|
||||
);
|
||||
|
||||
const getCumulativeScrollTop = useCallback((element: HTMLElement | null) => {
|
||||
let scrollTop = 0;
|
||||
while (element) {
|
||||
scrollTop += element.scrollTop;
|
||||
element = element.parentElement;
|
||||
}
|
||||
return scrollTop;
|
||||
}, []);
|
||||
|
||||
return {
|
||||
alignEndDateToTimeline,
|
||||
alignStartDateToTimeline,
|
||||
getCumulativeScrollTop,
|
||||
};
|
||||
};
|
||||
|
@ -26,6 +26,7 @@ import BirdseyeLivePlayer from "@/components/player/BirdseyeLivePlayer";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import SummaryTimeline from "@/components/timeline/SummaryTimeline";
|
||||
|
||||
// Color data
|
||||
const colors = [
|
||||
@ -125,6 +126,7 @@ const generateRandomEvent = (): ReviewSegment => {
|
||||
function UIPlayground() {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const reviewTimelineRef = useRef<HTMLDivElement>(null);
|
||||
const [mockEvents, setMockEvents] = useState<ReviewSegment[]>([]);
|
||||
const [mockMotionData, setMockMotionData] = useState<MotionData[]>([]);
|
||||
const [handlebarTime, setHandlebarTime] = useState(
|
||||
@ -144,7 +146,7 @@ function UIPlayground() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useMemo(() => {
|
||||
const initialEvents = Array.from({ length: 50 }, generateRandomEvent);
|
||||
const initialEvents = Array.from({ length: 10 }, generateRandomEvent);
|
||||
setMockEvents(initialEvents);
|
||||
setMockMotionData(generateRandomMotionAudioData());
|
||||
}, []);
|
||||
@ -403,9 +405,21 @@ function UIPlayground() {
|
||||
events={mockEvents} // events, including new has_been_reviewed and severity properties
|
||||
severityType={"alert"} // choose the severity type for the middle line - all other severity types are to the right
|
||||
contentRef={contentRef} // optional content ref where previews are, can be used for observing/scrolling later
|
||||
timelineRef={reviewTimelineRef}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{isEventsReviewTimeline && (
|
||||
<div className="w-[10px]">
|
||||
<SummaryTimeline
|
||||
reviewTimelineRef={reviewTimelineRef}
|
||||
timelineStart={Math.floor(Date.now() / 1000)} // timestamp start of the timeline - the earlier time
|
||||
timelineEnd={Math.floor(Date.now() / 1000) - 6 * 60 * 60} // end of timeline - the later time
|
||||
segmentDuration={zoomSettings.segmentDuration}
|
||||
events={mockEvents}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
|
@ -36,6 +36,7 @@ import { Button } from "@/components/ui/button";
|
||||
import PreviewPlayer, {
|
||||
PreviewController,
|
||||
} from "@/components/player/PreviewPlayer";
|
||||
import SummaryTimeline from "@/components/timeline/SummaryTimeline";
|
||||
import { RecordingStartingPoint } from "@/types/record";
|
||||
|
||||
type EventViewProps = {
|
||||
@ -329,6 +330,8 @@ function DetectionReview({
|
||||
onSelectReview,
|
||||
pullLatestData,
|
||||
}: DetectionReviewProps) {
|
||||
const reviewTimelineRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const segmentDuration = 60;
|
||||
|
||||
// review data
|
||||
@ -458,7 +461,7 @@ function DetectionReview({
|
||||
>
|
||||
{filter?.before == undefined && (
|
||||
<NewReviewData
|
||||
className="absolute w-full z-30"
|
||||
className="absolute w-full z-30 pointer-events-none"
|
||||
contentRef={contentRef}
|
||||
severity={severity}
|
||||
hasUpdate={hasUpdate}
|
||||
@ -527,21 +530,33 @@ function DetectionReview({
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-[55px] md:w-[100px] mt-2 overflow-y-auto no-scrollbar">
|
||||
<EventReviewTimeline
|
||||
segmentDuration={segmentDuration}
|
||||
timestampSpread={15}
|
||||
timelineStart={timeRange.before}
|
||||
timelineEnd={timeRange.after}
|
||||
showMinimap={showMinimap && !previewTime}
|
||||
minimapStartTime={minimapBounds.start}
|
||||
minimapEndTime={minimapBounds.end}
|
||||
showHandlebar={previewTime != undefined}
|
||||
handlebarTime={previewTime}
|
||||
events={reviewItems?.all ?? []}
|
||||
severityType={severity}
|
||||
contentRef={contentRef}
|
||||
/>
|
||||
<div className="w-[65px] md:w-[110px] mt-2 flex flex-row">
|
||||
<div className="w-[55px] md:w-[100px] overflow-y-auto no-scrollbar">
|
||||
<EventReviewTimeline
|
||||
segmentDuration={segmentDuration}
|
||||
timestampSpread={15}
|
||||
timelineStart={timeRange.before}
|
||||
timelineEnd={timeRange.after}
|
||||
showMinimap={showMinimap && !previewTime}
|
||||
minimapStartTime={minimapBounds.start}
|
||||
minimapEndTime={minimapBounds.end}
|
||||
showHandlebar={previewTime != undefined}
|
||||
handlebarTime={previewTime}
|
||||
events={reviewItems?.all ?? []}
|
||||
severityType={severity}
|
||||
contentRef={contentRef}
|
||||
timelineRef={reviewTimelineRef}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[10px]">
|
||||
<SummaryTimeline
|
||||
reviewTimelineRef={reviewTimelineRef}
|
||||
timelineStart={timeRange.before}
|
||||
timelineEnd={timeRange.after}
|
||||
segmentDuration={segmentDuration}
|
||||
events={reviewItems?.all ?? []}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
@ -22,16 +22,16 @@
|
||||
--primary: 0 0% 100%;
|
||||
|
||||
--primary-foreground: hsl(0, 0%, 0%);
|
||||
--primary-foreground: 0, 0%, 0%;
|
||||
--primary-foreground: 0 0% 0%;
|
||||
|
||||
--secondary: hsl(0, 0%, 96%);
|
||||
--secondary: 0, 0%, 96%;
|
||||
--secondary: 0 0% 96%;
|
||||
|
||||
--secondary-foreground: hsl(0, 0%, 45%);
|
||||
--secondary-foreground: 0, 0%, 45%;
|
||||
--secondary-foreground: 0 0% 45%;
|
||||
|
||||
--secondary-highlight: hsl(0, 0%, 94%);
|
||||
--secondary-highlight: 0, 0%, 94%;
|
||||
--secondary-highlight: 0 0% 94%;
|
||||
|
||||
--muted: hsl(210 40% 96.1%);
|
||||
--muted: 210 40% 96.1%;
|
||||
@ -104,28 +104,28 @@
|
||||
--popover-foreground: 210 40% 98%;
|
||||
|
||||
--primary: hsl(0, 0%, 9%);
|
||||
--primary: 0, 0%, 9%;
|
||||
--primary: 0 0% 9%;
|
||||
|
||||
--primary-foreground: hsl(0, 0%, 100%);
|
||||
--primary-foreground: 0, 0%, 100%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
|
||||
--secondary: hsl(0, 0%, 15%);
|
||||
--secondary: 0, 0%, 15%;
|
||||
--secondary: 0 0% 15%;
|
||||
|
||||
--secondary-foreground: hsl(0, 0%, 83%);
|
||||
--secondary-foreground: 0, 0%, 83%;
|
||||
--secondary-foreground: 0 0% 83%;
|
||||
|
||||
--secondary-highlight: hsl(0, 0%, 25%);
|
||||
--secondary-highlight: 0, 0%, 25%;
|
||||
--secondary-highlight: 0 0% 25%;
|
||||
|
||||
--muted: hsl(0, 0%, 8%);
|
||||
--muted: 0, 0%, 8%;
|
||||
--muted: 0 0% 8%;
|
||||
|
||||
--muted-foreground: hsl(0, 0%, 32%);
|
||||
--muted-foreground: 0, 0%, 32%;
|
||||
--muted-foreground: 0 0% 32%;
|
||||
|
||||
--accent: hsl(0, 0%, 15%);
|
||||
--accent: 0, 0%, 15%;
|
||||
--accent: 0 0% 15%;
|
||||
|
||||
--accent-foreground: hsl(210 40% 98%);
|
||||
--accent-foreground: 210 40% 98%;
|
||||
@ -137,7 +137,7 @@
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
|
||||
--border: hsl(0, 0%, 32%);
|
||||
--border: 0, 0%, 32%;
|
||||
--border: 0 0% 32%;
|
||||
|
||||
--input: hsl(217.2 32.6% 17.5%);
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
|
Loading…
Reference in New Issue
Block a user