This commit is contained in:
Josh Hawkins 2024-02-19 22:54:08 -06:00
parent 81397c79a5
commit 2a975cf51b
5 changed files with 732 additions and 0 deletions

View File

@ -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 (
<div key={segmentKey} className={segmentClasses}>
{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).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).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
month: "short",
day: "2-digit",
})}
</div>
)}
<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>
<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>
{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
${
showRoundedCorners &&
isStartOfEvent(segmentTime, events, segmentDuration)
? "rounded-bl-full rounded-br-full"
: ""
}
${
showRoundedCorners &&
isEndOfEvent(segmentTime, events, segmentDuration)
? "rounded-tl-full rounded-tr-full"
: ""
}
${
reviewed
? severity === 1
? "from-yellow-200/30 to-yellow-400/30"
: severity === 2
? "from-orange-400/30 to-orange-600/30"
: severity === 3
? "from-red-500/30 to-red-800/30"
: ""
: severity === 1
? "from-yellow-200 to-yellow-400"
: severity === 2
? "from-orange-400 to-orange-600"
: severity === 3
? "from-red-500 to-red-800"
: ""
}
`}
></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
${
showRoundedCorners &&
isStartOfEvent(segmentTime, events, segmentDuration)
? "rounded-bl-full rounded-br-full"
: ""
}
${
showRoundedCorners &&
isEndOfEvent(segmentTime, events, segmentDuration)
? "rounded-tl-full rounded-tr-full"
: ""
}
${
reviewed
? severity === 1
? "from-yellow-200/30 to-yellow-400/30"
: severity === 2
? "from-orange-400/30 to-orange-600/30"
: severity === 3
? "from-red-500/30 to-red-800/30"
: ""
: severity === 1
? "from-yellow-200 to-yellow-400"
: severity === 2
? "from-orange-400 to-orange-600"
: severity === 3
? "from-red-500 to-red-800"
: ""
}
`}
></div>
</div>
)}
</div>
);
}
export default EventSegment;

View File

@ -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<HTMLDivElement>;
};
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<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 * 1000;
return (
<EventSegment
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
).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 (
<div
ref={timelineRef}
className={`relative w-[100px] h-screen overflow-y-scroll no-scrollbar ${
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-red-500 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-red-500 top-1/2 transform -translate-y-1/2"></div>
</div>
</div>
</div>
)}
</div>
);
}
export default ReviewTimeline;
// TODO: more minor tick marks for segmentDuration < 60
// theme colors

View File

@ -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 };
};

View File

@ -0,0 +1,127 @@
import { useCallback } from "react";
interface DragHandlerProps {
contentRef: React.RefObject<HTMLElement>;
timelineRef: React.RefObject<HTMLDivElement>;
scrollTimeRef: React.RefObject<HTMLDivElement>;
alignDateToTimeline: (time: number) => number;
segmentDuration: number;
showHandlebar: boolean;
timelineDuration: number;
timelineStart: number;
isDragging: boolean;
setIsDragging: React.Dispatch<React.SetStateAction<boolean>>;
currentTimeRef: React.MutableRefObject<HTMLDivElement | null>;
}
// 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<HTMLDivElement>) => {
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;

View File

@ -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 };
};