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:
37
web/src/hooks/use-event-utils.ts
Normal file
37
web/src/hooks/use-event-utils.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useCallback } from 'react';
|
||||
import { ReviewSegment } from '@/types/review';
|
||||
|
||||
export const useEventUtils = (events: ReviewSegment[], segmentDuration: number) => {
|
||||
const isStartOfEvent = useCallback((time: number): boolean => {
|
||||
return events.some((event) => {
|
||||
const segmentStart = getSegmentStart(event.start_time);
|
||||
return time >= segmentStart && time < segmentStart + segmentDuration;
|
||||
});
|
||||
}, [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 && time < segmentEnd;
|
||||
}
|
||||
return false; // Return false if end_time is undefined
|
||||
});
|
||||
}, [events, segmentDuration]);
|
||||
|
||||
const getSegmentStart = useCallback((time: number): number => {
|
||||
return Math.floor(time / (segmentDuration)) * (segmentDuration);
|
||||
}, [segmentDuration]);
|
||||
|
||||
const getSegmentEnd = useCallback((time: number): number => {
|
||||
return Math.ceil(time / (segmentDuration)) * (segmentDuration);
|
||||
}, [segmentDuration]);
|
||||
|
||||
const alignDateToTimeline = useCallback((time: number): number => {
|
||||
const remainder = time % (segmentDuration);
|
||||
const adjustment = remainder !== 0 ? segmentDuration - remainder : 0;
|
||||
return time + adjustment;
|
||||
}, [segmentDuration]);
|
||||
|
||||
return { isStartOfEvent, isEndOfEvent, getSegmentStart, getSegmentEnd, alignDateToTimeline };
|
||||
};
|
||||
127
web/src/hooks/use-handle-dragging.ts
Normal file
127
web/src/hooks/use-handle-dragging.ts
Normal 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(
|
||||
timelineStart - segmentIndex * segmentDuration
|
||||
);
|
||||
|
||||
if (showHandlebar) {
|
||||
const thumb = scrollTimeRef.current;
|
||||
requestAnimationFrame(() => {
|
||||
thumb.style.top = `${newHandlePosition - segmentHeight}px`;
|
||||
if (currentTimeRef.current) {
|
||||
currentTimeRef.current.textContent = new Date(
|
||||
segmentStartTime*1000
|
||||
).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;
|
||||
134
web/src/hooks/use-segment-utils.ts
Normal file
134
web/src/hooks/use-segment-utils.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { ReviewSegment } from '@/types/review';
|
||||
|
||||
export const useSegmentUtils = (
|
||||
segmentDuration: number,
|
||||
events: ReviewSegment[],
|
||||
severityType: string,
|
||||
) => {
|
||||
const getSegmentStart = useCallback((time: number): number => {
|
||||
return Math.floor(time / (segmentDuration)) * (segmentDuration);
|
||||
}, [segmentDuration]);
|
||||
|
||||
const getSegmentEnd = useCallback((time: number | undefined): number => {
|
||||
if (time) {
|
||||
return Math.ceil(time / (segmentDuration)) * (segmentDuration);
|
||||
} else {
|
||||
return (Date.now()/1000)+(segmentDuration);
|
||||
}
|
||||
}, [segmentDuration]);
|
||||
|
||||
const mapSeverityToNumber = useCallback((severity: string): number => {
|
||||
switch (severity) {
|
||||
case "significant_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): { roundTop: boolean, roundBottom: boolean } => {
|
||||
|
||||
const prevSegmentTime = segmentTime - segmentDuration;
|
||||
const nextSegmentTime = segmentTime + segmentDuration;
|
||||
|
||||
const severityEvents = events.filter(e => e.severity === severityType);
|
||||
|
||||
const otherEvents = events.filter(e => e.severity !== severityType);
|
||||
|
||||
const hasPrevSeverityEvent = severityEvents.some(e => {
|
||||
return (
|
||||
prevSegmentTime >= getSegmentStart(e.start_time) &&
|
||||
prevSegmentTime < getSegmentEnd(e.end_time)
|
||||
);
|
||||
});
|
||||
|
||||
const hasNextSeverityEvent = severityEvents.some(e => {
|
||||
return (
|
||||
nextSegmentTime >= getSegmentStart(e.start_time) &&
|
||||
nextSegmentTime < getSegmentEnd(e.end_time)
|
||||
);
|
||||
});
|
||||
|
||||
const hasPrevOtherEvent = otherEvents.some(e => {
|
||||
return (
|
||||
prevSegmentTime >= getSegmentStart(e.start_time) &&
|
||||
prevSegmentTime < getSegmentEnd(e.end_time)
|
||||
);
|
||||
});
|
||||
|
||||
const hasNextOtherEvent = otherEvents.some(e => {
|
||||
return (
|
||||
nextSegmentTime >= getSegmentStart(e.start_time) &&
|
||||
nextSegmentTime < getSegmentEnd(e.end_time)
|
||||
);
|
||||
});
|
||||
|
||||
const hasOverlappingSeverityEvent = severityEvents.some(e => {
|
||||
return segmentTime >= getSegmentStart(e.start_time) &&
|
||||
segmentTime < getSegmentEnd(e.end_time)
|
||||
});
|
||||
|
||||
const hasOverlappingOtherEvent = otherEvents.some(e => {
|
||||
return segmentTime >= getSegmentStart(e.start_time) &&
|
||||
segmentTime < getSegmentEnd(e.end_time)
|
||||
});
|
||||
|
||||
let roundTop = false;
|
||||
let roundBottom = false;
|
||||
|
||||
if (hasOverlappingSeverityEvent) {
|
||||
roundBottom = !hasPrevSeverityEvent;
|
||||
roundTop = !hasNextSeverityEvent;
|
||||
} else if (hasOverlappingOtherEvent) {
|
||||
roundBottom = !hasPrevOtherEvent;
|
||||
roundTop = !hasNextOtherEvent;
|
||||
} else {
|
||||
roundTop = !hasNextSeverityEvent || !hasNextOtherEvent;
|
||||
roundBottom = !hasPrevSeverityEvent || !hasPrevOtherEvent;
|
||||
}
|
||||
|
||||
return {
|
||||
roundTop,
|
||||
roundBottom
|
||||
};
|
||||
|
||||
},
|
||||
[events, getSegmentStart, getSegmentEnd, segmentDuration, severityType]
|
||||
);
|
||||
|
||||
return { getSegmentStart, getSegmentEnd, getSeverity, displaySeverityType, getReviewed, shouldShowRoundedCorners };
|
||||
};
|
||||
Reference in New Issue
Block a user