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:
Josh Hawkins
2024-02-20 17:22:59 -06:00
committed by GitHub
parent aa99e11e1a
commit cdd6ac9071
11 changed files with 1231 additions and 18 deletions

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

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(
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;

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