diff --git a/web/src/components/timeline/EventSegment.tsx b/web/src/components/timeline/EventSegment.tsx new file mode 100644 index 000000000..a4c700f8e --- /dev/null +++ b/web/src/components/timeline/EventSegment.tsx @@ -0,0 +1,236 @@ +import { useEventUtils } from "@/hooks/use-event-utils"; +import { useSegmentUtils } from "@/hooks/use-segment-utils"; +import { Event } from "@/types/event"; +import { useMemo } from "react"; + +type EventSegmentProps = { + events: Event[]; + segmentTime: number; + segmentDuration: number; + timestampSpread: number; + showMinimap: boolean; + minimapStartTime?: number; + minimapEndTime?: number; + severityType: string; +}; + +export function EventSegment({ + events, + segmentTime, + segmentDuration, + timestampSpread, + showMinimap, + minimapStartTime, + minimapEndTime, + severityType, +}: EventSegmentProps) { + const { isStartOfEvent, isEndOfEvent } = useEventUtils( + events, + segmentDuration + ); + 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 showRoundedCorners = useMemo( + () => shouldShowRoundedCorners(segmentTime), + [shouldShowRoundedCorners, segmentTime] + ); + + const timestamp = useMemo(() => new Date(segmentTime), [segmentTime]); + const segmentKey = useMemo( + () => Math.floor(segmentTime / 1000), + [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-muted" + : isLastSegmentInMinimap + ? "" + : "opacity-80" + : "" + } ${ + isFirstSegmentInMinimap || isLastSegmentInMinimap + ? "relative h-2 border-b border-gray-500" + : "" + }`; + + return ( +
+ {isFirstSegmentInMinimap && ( +
+ {new Date(alignedMinimapStartTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + month: "short", + day: "2-digit", + })} +
+ )} + + {isLastSegmentInMinimap && ( +
+ {new Date(alignedMinimapEndTime).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + month: "short", + day: "2-digit", + })} +
+ )} +
+ {!isFirstSegmentInMinimap && !isLastSegmentInMinimap && ( +
+ )} +
+
+ {!isFirstSegmentInMinimap && !isLastSegmentInMinimap && ( +
+ {timestamp.getMinutes() % timestampSpread === 0 && + timestamp.getSeconds() === 0 && + timestamp.toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + })} +
+ )} +
+ + {severity == displaySeverityType && ( +
+
+
+ )} + + {severity != displaySeverityType && ( +
+
+
+ )} +
+ ); +} + +export default EventSegment; diff --git a/web/src/components/timeline/ReviewTimeline.tsx b/web/src/components/timeline/ReviewTimeline.tsx new file mode 100644 index 000000000..bfd682fd5 --- /dev/null +++ b/web/src/components/timeline/ReviewTimeline.tsx @@ -0,0 +1,243 @@ +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 { Event } from "@/types/event"; + +export type ReviewTimelineProps = { + segmentDuration: number; + timestampSpread: number; + timelineStart: number; + timelineDuration?: number; + showHandlebar?: boolean; + handlebarTime?: number; + showMinimap?: boolean; + minimapStartTime?: number; + minimapEndTime?: number; + events: Event[]; + severityType: string; + contentRef: RefObject; +}; + +export function ReviewTimeline({ + segmentDuration, + timestampSpread, + timelineStart, + timelineDuration = 24 * 60 * 60, + showHandlebar = false, + handlebarTime, + showMinimap = false, + minimapStartTime, + minimapEndTime, + events, + severityType, + contentRef, +}: ReviewTimelineProps) { + const [isDragging, setIsDragging] = useState(false); + const [currentTimeSegment, setCurrentTimeSegment] = useState(0); + const scrollTimeRef = useRef(null); + const timelineRef = useRef(null); + const currentTimeRef = useRef(null); + const observer = useRef(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 * 1000; + + return ( + + ); + }); + }, [ + 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 + ).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 * 1000) + ); + + // 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 ( +
+
{segments}
+ {showHandlebar && ( +
+
+
+
+
+
+
+
+
+
+ )} +
+ ); +} + +export default ReviewTimeline; + +// TODO: more minor tick marks for segmentDuration < 60 +// theme colors diff --git a/web/src/hooks/use-event-utils.ts b/web/src/hooks/use-event-utils.ts new file mode 100644 index 000000000..f0aa38f70 --- /dev/null +++ b/web/src/hooks/use-event-utils.ts @@ -0,0 +1,37 @@ +import { useCallback } from 'react'; +import { Event } from '@/types/event'; + +export const useEventUtils = (events: Event[], segmentDuration: number) => { + const isStartOfEvent = useCallback((time: number): boolean => { + return events.some((event) => { + const segmentStart = getSegmentStart(event.start_time); + return time >= segmentStart && time < segmentStart + segmentDuration * 1000; + }); + }, [events, segmentDuration]); + + const isEndOfEvent = useCallback((time: number): boolean => { + return events.some((event) => { + if (typeof event.end_time === 'number') { + const segmentEnd = getSegmentEnd(event.end_time); + return time >= segmentEnd - segmentDuration * 1000 && time < segmentEnd; + } + return false; // Return false if end_time is undefined + }); + }, [events, segmentDuration]); + + const getSegmentStart = useCallback((time: number): number => { + return Math.floor(time / (segmentDuration * 1000)) * (segmentDuration * 1000); + }, [segmentDuration]); + + const getSegmentEnd = useCallback((time: number): number => { + return Math.ceil(time / (segmentDuration * 1000)) * (segmentDuration * 1000); + }, [segmentDuration]); + + const alignDateToTimeline = useCallback((time: number): number => { + const remainder = time % (segmentDuration * 1000); + const adjustment = remainder !== 0 ? segmentDuration * 1000 - remainder : 0; + return time + adjustment; + }, [segmentDuration]); + + return { isStartOfEvent, isEndOfEvent, getSegmentStart, getSegmentEnd, alignDateToTimeline }; +}; diff --git a/web/src/hooks/use-handle-dragging.ts b/web/src/hooks/use-handle-dragging.ts new file mode 100644 index 000000000..d0e180646 --- /dev/null +++ b/web/src/hooks/use-handle-dragging.ts @@ -0,0 +1,127 @@ +import { useCallback } from "react"; + +interface DragHandlerProps { + contentRef: React.RefObject; + timelineRef: React.RefObject; + scrollTimeRef: React.RefObject; + alignDateToTimeline: (time: number) => number; + segmentDuration: number; + showHandlebar: boolean; + timelineDuration: number; + timelineStart: number; + isDragging: boolean; + setIsDragging: React.Dispatch>; + currentTimeRef: React.MutableRefObject; +} + +// TODO: handle mobile touch events +function useDraggableHandler({ + contentRef, + timelineRef, + scrollTimeRef, + alignDateToTimeline, + segmentDuration, + showHandlebar, + timelineDuration, + timelineStart, + isDragging, + setIsDragging, + currentTimeRef, +}: DragHandlerProps) { + const handleMouseDown = useCallback( + (e: React.MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsDragging(true); + }, + [setIsDragging] + ); + + const handleMouseUp = useCallback( + (e: MouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + if (isDragging) { + setIsDragging(false); + } + }, + [isDragging, setIsDragging] + ); + + const handleMouseMove = useCallback( + (e: MouseEvent) => { + if (!contentRef.current || !timelineRef.current || !scrollTimeRef.current) { + return; + } + + e.preventDefault(); + e.stopPropagation(); + + if (isDragging) { + const { + scrollHeight: timelineHeight, + clientHeight: visibleTimelineHeight, + scrollTop: scrolled, + offsetTop: timelineTop, + } = timelineRef.current; + + const segmentHeight = + timelineHeight / (timelineDuration / segmentDuration); + + const getCumulativeScrollTop = ( + element: HTMLElement | null + ) => { + let scrollTop = 0; + while (element) { + scrollTop += element.scrollTop; + element = element.parentElement; + } + return scrollTop; + }; + + const parentScrollTop = getCumulativeScrollTop(timelineRef.current); + + const newHandlePosition = Math.min( + visibleTimelineHeight - timelineTop + parentScrollTop, + Math.max( + segmentHeight + scrolled, + e.clientY - timelineTop + parentScrollTop + ) + ); + + const segmentIndex = Math.floor(newHandlePosition / segmentHeight); + const segmentStartTime = alignDateToTimeline( + Math.floor(timelineStart - segmentIndex * segmentDuration * 1000) + ); + + if (showHandlebar) { + const thumb = scrollTimeRef.current; + requestAnimationFrame(() => { + thumb.style.top = `${newHandlePosition - segmentHeight}px`; + if (currentTimeRef.current) { + currentTimeRef.current.textContent = new Date( + segmentStartTime + ).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + ...(segmentDuration < 60 && { second: "2-digit" }), + }); + } + }); + } + } + }, + [ + isDragging, + contentRef, + segmentDuration, + showHandlebar, + timelineDuration, + timelineStart, + ] + ); + + return { handleMouseDown, handleMouseUp, handleMouseMove }; +} + +export default useDraggableHandler; diff --git a/web/src/hooks/use-segment-utils.ts b/web/src/hooks/use-segment-utils.ts new file mode 100644 index 000000000..09b2ef493 --- /dev/null +++ b/web/src/hooks/use-segment-utils.ts @@ -0,0 +1,89 @@ +import { useCallback, useMemo } from 'react'; +import { Event } from '@/types/event'; + +export const useSegmentUtils = ( + segmentDuration: number, + events: Event[], + severityType: string, +) => { + const getSegmentStart = useCallback((time: number): number => { + return Math.floor(time / (segmentDuration * 1000)) * (segmentDuration * 1000); + }, [segmentDuration]); + + const getSegmentEnd = useCallback((time: number | undefined): number => { + if (time) { + return Math.ceil(time / (segmentDuration * 1000)) * (segmentDuration * 1000); + } else { + return Date.now()+(segmentDuration*1000); + } + }, [segmentDuration]); + + const mapSeverityToNumber = useCallback((severity: string): number => { + switch (severity) { + case "motion": + return 1; + case "detection": + return 2; + case "alert": + return 3; + default: + return 0; + } + }, []); + + const displaySeverityType = useMemo( + () => mapSeverityToNumber(severityType ?? ""), + [severityType] + ); + + const getSeverity = useCallback((time: number): number => { + const activeEvents = events?.filter((event) => { + const segmentStart = getSegmentStart(event.start_time); + const segmentEnd = getSegmentEnd(event.end_time); + return time >= segmentStart && time < segmentEnd; + }); + if (activeEvents?.length === 0) return 0; // No event at this time + const severityValues = activeEvents?.map((event) => + mapSeverityToNumber(event.severity) + ); + return Math.max(...severityValues); + }, [events, getSegmentStart, getSegmentEnd, mapSeverityToNumber]); + + const getReviewed = useCallback((time: number): boolean => { + return events.some((event) => { + const segmentStart = getSegmentStart(event.start_time); + const segmentEnd = getSegmentEnd(event.end_time); + return ( + time >= segmentStart && time < segmentEnd && event.has_been_reviewed + ); + }); + }, [events, getSegmentStart, getSegmentEnd]); + + const shouldShowRoundedCorners = useCallback( + (segmentTime: number): boolean => { + const prevSegmentTime = segmentTime - segmentDuration * 1000; + const nextSegmentTime = segmentTime + segmentDuration * 1000; + + const hasPrevEvent = events.some((e) => { + return ( + prevSegmentTime >= getSegmentStart(e.start_time) && + prevSegmentTime < getSegmentEnd(e.end_time) && + e.severity === severityType + ); + }); + + const hasNextEvent = events.some((e) => { + return ( + nextSegmentTime >= getSegmentStart(e.start_time) && + nextSegmentTime < getSegmentEnd(e.end_time) && + e.severity === severityType + ); + }); + + return !hasPrevEvent || !hasNextEvent; + }, + [events, getSegmentStart, getSegmentEnd, segmentDuration, severityType] + ); + + return { getSegmentStart, getSegmentEnd, getSeverity, displaySeverityType, getReviewed, shouldShowRoundedCorners }; +};