forked from Github/frigate
Implement event review timeline (#9941)
* initial implementation of review timeline * hooks * clean up and comments * reorganize components * colors and tweaks * remove touch events for now * remove touch events for now * fix vite config * use unix timestamps everywhere * fix corner rounding * comparison * use ReviewSegment type * update mock review event generator * severity type enum * remove testing code
This commit is contained in:
241
web/src/components/timeline/EventReviewTimeline.tsx
Normal file
241
web/src/components/timeline/EventReviewTimeline.tsx
Normal file
@@ -0,0 +1,241 @@
|
||||
import useDraggableHandler from "@/hooks/use-handle-dragging";
|
||||
import {
|
||||
useEffect,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
RefObject,
|
||||
} from "react";
|
||||
import EventSegment from "./EventSegment";
|
||||
import { useEventUtils } from "@/hooks/use-event-utils";
|
||||
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||
|
||||
export type EventReviewTimelineProps = {
|
||||
segmentDuration: number;
|
||||
timestampSpread: number;
|
||||
timelineStart: number;
|
||||
timelineDuration?: number;
|
||||
showHandlebar?: boolean;
|
||||
handlebarTime?: number;
|
||||
showMinimap?: boolean;
|
||||
minimapStartTime?: number;
|
||||
minimapEndTime?: number;
|
||||
events: ReviewSegment[];
|
||||
severityType: ReviewSeverity;
|
||||
contentRef: RefObject<HTMLDivElement>;
|
||||
};
|
||||
|
||||
export function EventReviewTimeline({
|
||||
segmentDuration,
|
||||
timestampSpread,
|
||||
timelineStart,
|
||||
timelineDuration = 24 * 60 * 60,
|
||||
showHandlebar = false,
|
||||
handlebarTime,
|
||||
showMinimap = false,
|
||||
minimapStartTime,
|
||||
minimapEndTime,
|
||||
events,
|
||||
severityType,
|
||||
contentRef,
|
||||
}: EventReviewTimelineProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [currentTimeSegment, setCurrentTimeSegment] = useState<number>(0);
|
||||
const scrollTimeRef = useRef<HTMLDivElement>(null);
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const currentTimeRef = useRef<HTMLDivElement>(null);
|
||||
const observer = useRef<ResizeObserver | null>(null);
|
||||
|
||||
const { alignDateToTimeline } = useEventUtils(events, segmentDuration);
|
||||
|
||||
const { handleMouseDown, handleMouseUp, handleMouseMove } =
|
||||
useDraggableHandler({
|
||||
contentRef,
|
||||
timelineRef,
|
||||
scrollTimeRef,
|
||||
alignDateToTimeline,
|
||||
segmentDuration,
|
||||
showHandlebar,
|
||||
timelineDuration,
|
||||
timelineStart,
|
||||
isDragging,
|
||||
setIsDragging,
|
||||
currentTimeRef,
|
||||
});
|
||||
|
||||
function handleResize() {
|
||||
// TODO: handle screen resize for mobile
|
||||
if (timelineRef.current && contentRef.current) {
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (contentRef.current) {
|
||||
const content = contentRef.current;
|
||||
observer.current = new ResizeObserver(() => {
|
||||
handleResize();
|
||||
});
|
||||
observer.current.observe(content);
|
||||
return () => {
|
||||
observer.current?.unobserve(content);
|
||||
};
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Generate segments for the timeline
|
||||
const generateSegments = useCallback(() => {
|
||||
const segmentCount = timelineDuration / segmentDuration;
|
||||
const segmentAlignedTime = alignDateToTimeline(timelineStart);
|
||||
|
||||
return Array.from({ length: segmentCount }, (_, index) => {
|
||||
const segmentTime = segmentAlignedTime - index * segmentDuration;
|
||||
|
||||
return (
|
||||
<EventSegment
|
||||
key={segmentTime}
|
||||
events={events}
|
||||
segmentDuration={segmentDuration}
|
||||
segmentTime={segmentTime}
|
||||
timestampSpread={timestampSpread}
|
||||
showMinimap={showMinimap}
|
||||
minimapStartTime={minimapStartTime}
|
||||
minimapEndTime={minimapEndTime}
|
||||
severityType={severityType}
|
||||
/>
|
||||
);
|
||||
});
|
||||
}, [
|
||||
segmentDuration,
|
||||
timestampSpread,
|
||||
timelineStart,
|
||||
timelineDuration,
|
||||
showMinimap,
|
||||
minimapStartTime,
|
||||
minimapEndTime,
|
||||
]);
|
||||
|
||||
const segments = useMemo(
|
||||
() => generateSegments(),
|
||||
[
|
||||
segmentDuration,
|
||||
timestampSpread,
|
||||
timelineStart,
|
||||
timelineDuration,
|
||||
showMinimap,
|
||||
minimapStartTime,
|
||||
minimapEndTime,
|
||||
events,
|
||||
]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (showHandlebar) {
|
||||
requestAnimationFrame(() => {
|
||||
if (currentTimeRef.current && currentTimeSegment) {
|
||||
currentTimeRef.current.textContent = new Date(
|
||||
currentTimeSegment * 1000
|
||||
).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
...(segmentDuration < 60 && { second: "2-digit" }),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}, [currentTimeSegment, showHandlebar]);
|
||||
|
||||
useEffect(() => {
|
||||
if (timelineRef.current && handlebarTime && showHandlebar) {
|
||||
const { scrollHeight: timelineHeight } = timelineRef.current;
|
||||
|
||||
// Calculate the height of an individual segment
|
||||
const segmentHeight =
|
||||
timelineHeight / (timelineDuration / segmentDuration);
|
||||
|
||||
// Calculate the segment index corresponding to the target time
|
||||
const alignedHandlebarTime = alignDateToTimeline(handlebarTime);
|
||||
const segmentIndex = Math.ceil(
|
||||
(timelineStart - alignedHandlebarTime) / segmentDuration
|
||||
);
|
||||
|
||||
// Calculate the top position based on the segment index
|
||||
const newTopPosition = Math.max(0, segmentIndex * segmentHeight);
|
||||
|
||||
// Set the top position of the handle
|
||||
const thumb = scrollTimeRef.current;
|
||||
if (thumb) {
|
||||
requestAnimationFrame(() => {
|
||||
thumb.style.top = `${newTopPosition}px`;
|
||||
});
|
||||
}
|
||||
|
||||
setCurrentTimeSegment(alignedHandlebarTime);
|
||||
}
|
||||
}, [
|
||||
handlebarTime,
|
||||
segmentDuration,
|
||||
showHandlebar,
|
||||
timelineDuration,
|
||||
timelineStart,
|
||||
alignDateToTimeline,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
generateSegments();
|
||||
if (!currentTimeSegment && !handlebarTime) {
|
||||
setCurrentTimeSegment(timelineStart);
|
||||
}
|
||||
// TODO: touch events for mobile
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [
|
||||
currentTimeSegment,
|
||||
generateSegments,
|
||||
timelineStart,
|
||||
handleMouseUp,
|
||||
handleMouseMove,
|
||||
]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={timelineRef}
|
||||
className={`relative w-[120px] md:w-[100px] h-[100dvh] overflow-y-scroll no-scrollbar bg-secondary ${
|
||||
isDragging && showHandlebar ? "cursor-grabbing" : "cursor-auto"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col">{segments}</div>
|
||||
{showHandlebar && (
|
||||
<div className={`absolute left-0 top-0 z-20 w-full `} role="scrollbar">
|
||||
<div className={`flex items-center justify-center `}>
|
||||
<div
|
||||
ref={scrollTimeRef}
|
||||
className={`relative w-full ${
|
||||
isDragging ? "cursor-grabbing" : "cursor-grab"
|
||||
}`}
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<div
|
||||
className={`bg-destructive rounded-full mx-auto ${
|
||||
segmentDuration < 60 ? "w-20" : "w-16"
|
||||
} h-5 flex items-center justify-center`}
|
||||
>
|
||||
<div
|
||||
ref={currentTimeRef}
|
||||
className="text-white text-xs z-10"
|
||||
></div>
|
||||
</div>
|
||||
<div className="absolute h-1 w-full bg-destructive top-1/2 transform -translate-y-1/2"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventReviewTimeline;
|
||||
265
web/src/components/timeline/EventSegment.tsx
Normal file
265
web/src/components/timeline/EventSegment.tsx
Normal file
@@ -0,0 +1,265 @@
|
||||
import { useEventUtils } from "@/hooks/use-event-utils";
|
||||
import { useSegmentUtils } from "@/hooks/use-segment-utils";
|
||||
import { ReviewSegment, ReviewSeverity } from "@/types/review";
|
||||
import { useMemo } from "react";
|
||||
|
||||
type EventSegmentProps = {
|
||||
events: ReviewSegment[];
|
||||
segmentTime: number;
|
||||
segmentDuration: number;
|
||||
timestampSpread: number;
|
||||
showMinimap: boolean;
|
||||
minimapStartTime?: number;
|
||||
minimapEndTime?: number;
|
||||
severityType: ReviewSeverity;
|
||||
};
|
||||
|
||||
type MinimapSegmentProps = {
|
||||
isFirstSegmentInMinimap: boolean;
|
||||
isLastSegmentInMinimap: boolean;
|
||||
alignedMinimapStartTime: number;
|
||||
alignedMinimapEndTime: number;
|
||||
};
|
||||
|
||||
type TickSegmentProps = {
|
||||
isFirstSegmentInMinimap: boolean;
|
||||
isLastSegmentInMinimap: boolean;
|
||||
timestamp: Date;
|
||||
timestampSpread: number;
|
||||
};
|
||||
|
||||
type TimestampSegmentProps = {
|
||||
isFirstSegmentInMinimap: boolean;
|
||||
isLastSegmentInMinimap: boolean;
|
||||
timestamp: Date;
|
||||
timestampSpread: number;
|
||||
segmentKey: number;
|
||||
};
|
||||
|
||||
function MinimapBounds({
|
||||
isFirstSegmentInMinimap,
|
||||
isLastSegmentInMinimap,
|
||||
alignedMinimapStartTime,
|
||||
alignedMinimapEndTime,
|
||||
}: MinimapSegmentProps) {
|
||||
return (
|
||||
<>
|
||||
{isFirstSegmentInMinimap && (
|
||||
<div className="absolute inset-0 -bottom-5 w-full flex items-center justify-center text-xs text-primary font-medium z-20 text-center text-[9px]">
|
||||
{new Date(alignedMinimapStartTime * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLastSegmentInMinimap && (
|
||||
<div className="absolute inset-0 -top-1 w-full flex items-center justify-center text-xs text-primary font-medium z-20 text-center text-[9px]">
|
||||
{new Date(alignedMinimapEndTime * 1000).toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Tick({
|
||||
isFirstSegmentInMinimap,
|
||||
isLastSegmentInMinimap,
|
||||
timestamp,
|
||||
timestampSpread,
|
||||
}: TickSegmentProps) {
|
||||
return (
|
||||
<div className="w-5 h-2 flex justify-left items-end">
|
||||
{!isFirstSegmentInMinimap && !isLastSegmentInMinimap && (
|
||||
<div
|
||||
className={`h-0.5 ${
|
||||
timestamp.getMinutes() % timestampSpread === 0 &&
|
||||
timestamp.getSeconds() === 0
|
||||
? "w-4 bg-gray-400"
|
||||
: "w-2 bg-gray-600"
|
||||
}`}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Timestamp({
|
||||
isFirstSegmentInMinimap,
|
||||
isLastSegmentInMinimap,
|
||||
timestamp,
|
||||
timestampSpread,
|
||||
segmentKey,
|
||||
}: TimestampSegmentProps) {
|
||||
return (
|
||||
<div className="w-10 h-2 flex justify-left items-top z-10">
|
||||
{!isFirstSegmentInMinimap && !isLastSegmentInMinimap && (
|
||||
<div
|
||||
key={`${segmentKey}_timestamp`}
|
||||
className="text-[8px] text-gray-400"
|
||||
>
|
||||
{timestamp.getMinutes() % timestampSpread === 0 &&
|
||||
timestamp.getSeconds() === 0 &&
|
||||
timestamp.toLocaleTimeString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EventSegment({
|
||||
events,
|
||||
segmentTime,
|
||||
segmentDuration,
|
||||
timestampSpread,
|
||||
showMinimap,
|
||||
minimapStartTime,
|
||||
minimapEndTime,
|
||||
severityType,
|
||||
}: EventSegmentProps) {
|
||||
const {
|
||||
getSeverity,
|
||||
getReviewed,
|
||||
displaySeverityType,
|
||||
shouldShowRoundedCorners,
|
||||
} = useSegmentUtils(segmentDuration, events, severityType);
|
||||
|
||||
const { alignDateToTimeline } = useEventUtils(events, segmentDuration);
|
||||
|
||||
const severity = useMemo(
|
||||
() => getSeverity(segmentTime),
|
||||
[getSeverity, segmentTime]
|
||||
);
|
||||
const reviewed = useMemo(
|
||||
() => getReviewed(segmentTime),
|
||||
[getReviewed, segmentTime]
|
||||
);
|
||||
const { roundTop, roundBottom } = useMemo(
|
||||
() => shouldShowRoundedCorners(segmentTime),
|
||||
[shouldShowRoundedCorners, segmentTime]
|
||||
);
|
||||
|
||||
const timestamp = useMemo(() => new Date(segmentTime * 1000), [segmentTime]);
|
||||
const segmentKey = useMemo(() => segmentTime, [segmentTime]);
|
||||
|
||||
const alignedMinimapStartTime = useMemo(
|
||||
() => alignDateToTimeline(minimapStartTime ?? 0),
|
||||
[minimapStartTime, alignDateToTimeline]
|
||||
);
|
||||
const alignedMinimapEndTime = useMemo(
|
||||
() => alignDateToTimeline(minimapEndTime ?? 0),
|
||||
[minimapEndTime, alignDateToTimeline]
|
||||
);
|
||||
|
||||
const isInMinimapRange = useMemo(() => {
|
||||
return (
|
||||
showMinimap &&
|
||||
minimapStartTime &&
|
||||
minimapEndTime &&
|
||||
segmentTime > minimapStartTime &&
|
||||
segmentTime < minimapEndTime
|
||||
);
|
||||
}, [showMinimap, minimapStartTime, minimapEndTime, segmentTime]);
|
||||
|
||||
const isFirstSegmentInMinimap = useMemo(() => {
|
||||
return showMinimap && segmentTime === alignedMinimapStartTime;
|
||||
}, [showMinimap, segmentTime, alignedMinimapStartTime]);
|
||||
|
||||
const isLastSegmentInMinimap = useMemo(() => {
|
||||
return showMinimap && segmentTime === alignedMinimapEndTime;
|
||||
}, [showMinimap, segmentTime, alignedMinimapEndTime]);
|
||||
|
||||
const segmentClasses = `flex flex-row ${
|
||||
showMinimap
|
||||
? isInMinimapRange
|
||||
? "bg-card"
|
||||
: isLastSegmentInMinimap
|
||||
? ""
|
||||
: "opacity-70"
|
||||
: ""
|
||||
} ${
|
||||
isFirstSegmentInMinimap || isLastSegmentInMinimap
|
||||
? "relative h-2 border-b border-gray-500"
|
||||
: ""
|
||||
}`;
|
||||
|
||||
const severityColors: { [key: number]: string } = {
|
||||
1: reviewed
|
||||
? "from-severity_motion-dimmed/30 to-severity_motion/30"
|
||||
: "from-severity_motion-dimmed to-severity_motion",
|
||||
2: reviewed
|
||||
? "from-severity_detection-dimmed/30 to-severity_detection/30"
|
||||
: "from-severity_detection-dimmed to-severity_detection",
|
||||
3: reviewed
|
||||
? "from-severity_alert-dimmed/30 to-severity_alert/30"
|
||||
: "from-severity_alert-dimmed to-severity_alert",
|
||||
};
|
||||
|
||||
return (
|
||||
<div key={segmentKey} className={segmentClasses}>
|
||||
<MinimapBounds
|
||||
isFirstSegmentInMinimap={isFirstSegmentInMinimap}
|
||||
isLastSegmentInMinimap={isLastSegmentInMinimap}
|
||||
alignedMinimapStartTime={alignedMinimapStartTime}
|
||||
alignedMinimapEndTime={alignedMinimapEndTime}
|
||||
/>
|
||||
|
||||
<Tick
|
||||
isFirstSegmentInMinimap={isFirstSegmentInMinimap}
|
||||
isLastSegmentInMinimap={isLastSegmentInMinimap}
|
||||
timestamp={timestamp}
|
||||
timestampSpread={timestampSpread}
|
||||
/>
|
||||
|
||||
<Timestamp
|
||||
isFirstSegmentInMinimap={isFirstSegmentInMinimap}
|
||||
isLastSegmentInMinimap={isLastSegmentInMinimap}
|
||||
timestamp={timestamp}
|
||||
timestampSpread={timestampSpread}
|
||||
segmentKey={segmentKey}
|
||||
/>
|
||||
|
||||
{severity == displaySeverityType && (
|
||||
<div className="mr-3 w-2 h-2 flex justify-left items-end">
|
||||
<div
|
||||
key={`${segmentKey}_primary_data`}
|
||||
className={`
|
||||
w-full h-2 bg-gradient-to-r
|
||||
${roundBottom ? "rounded-bl-full rounded-br-full" : ""}
|
||||
${roundTop ? "rounded-tl-full rounded-tr-full" : ""}
|
||||
${severityColors[severity]}
|
||||
|
||||
`}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{severity != displaySeverityType && (
|
||||
<div className="h-2 flex flex-grow justify-end items-end">
|
||||
<div
|
||||
key={`${segmentKey}_secondary_data`}
|
||||
className={`
|
||||
w-1 h-2 bg-gradient-to-r
|
||||
${roundBottom ? "rounded-bl-full rounded-br-full" : ""}
|
||||
${roundTop ? "rounded-tl-full rounded-tr-full" : ""}
|
||||
${severityColors[severity]}
|
||||
|
||||
`}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EventSegment;
|
||||
Reference in New Issue
Block a user