diff --git a/web/src/components/HistoryViewer.jsx b/web/src/components/HistoryViewer.jsx index 3e2bb9e96..2951e0d2c 100644 --- a/web/src/components/HistoryViewer.jsx +++ b/web/src/components/HistoryViewer.jsx @@ -7,7 +7,7 @@ import { Play } from '../icons/Play'; import { Previous } from '../icons/Previous'; import { HistoryHeader } from '../routes/HistoryHeader'; import { longToDate } from '../utils/dateUtil'; -import Timeline from './Timeline'; +import Timeline from './Timeline/Timeline'; const getLast24Hours = () => { return new Number(new Date(new Date().getTime() - 24 * 60 * 60 * 1000)) / 1000; @@ -51,10 +51,7 @@ export default function HistoryViewer({ camera }) { const handleTimelineChange = (timelineChangedEvent) => { if (timelineChangedEvent.seekComplete) { - const currentEventExists = currentEvent !== undefined; - if (!currentEventExists || currentEvent.id !== timelineChangedEvent.event.id) { - setCurrentEvent(timelineChangedEvent.event); - } + setCurrentEvent(timelineChangedEvent.event); } const videoContainer = videoRef.current; @@ -143,7 +140,8 @@ export default function HistoryViewer({ camera }) {
@@ -156,7 +154,7 @@ export default function HistoryViewer({ camera }) { events={timelineEvents} offset={timelineOffset} currentIndex={currentEventIndex} - disabled={isPlaying} + disableMarkerEvents={isPlaying} onChange={handleTimelineChange} /> diff --git a/web/src/components/Timeline.jsx b/web/src/components/Timeline.jsx deleted file mode 100644 index 2cfd82208..000000000 --- a/web/src/components/Timeline.jsx +++ /dev/null @@ -1,217 +0,0 @@ -import { h } from 'preact'; -import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; -import { longToDate } from '../utils/dateUtil'; - -export default function Timeline({ events, offset, currentIndex, disabled, onChange }) { - const timelineContainerRef = useRef(undefined); - - const [timeline, setTimeline] = useState([]); - const [timelineOffset, setTimelineOffset] = useState(); - const [markerTime, setMarkerTime] = useState(); - const [currentEvent, setCurrentEvent] = useState(); - const [scrollTimeout, setScrollTimeout] = useState(); - const [scrollActive, setScrollActive] = useState(true); - const [eventsEnabled, setEventsEnable] = useState(true); - - useEffect(() => { - if (events && events.length > 0 && timelineOffset) { - const firstEvent = events[0]; - if (firstEvent) { - setMarkerTime(longToDate(firstEvent.start_time)); - } - - const firstEventTime = longToDate(firstEvent.start_time); - const timelineEvents = events.map((e, i) => { - const startTime = longToDate(e.start_time); - const endTime = e.end_time ? longToDate(e.end_time) : new Date(); - const seconds = Math.round(Math.abs(endTime - startTime) / 1000); - const positionX = Math.round(Math.abs(startTime - firstEventTime) / 1000 + timelineOffset); - return { - ...e, - startTime, - endTime, - seconds, - width: seconds, - positionX, - }; - }); - - const firstTimelineEvent = timelineEvents[0]; - setCurrentEvent({ - ...firstTimelineEvent, - id: firstTimelineEvent.id, - index: 0, - startTime: longToDate(firstTimelineEvent.start_time), - endTime: longToDate(firstTimelineEvent.end_time), - }); - setTimeline(timelineEvents); - } - }, [events, timelineOffset]); - - const getCurrentEvent = useCallback(() => { - return currentEvent; - }, [currentEvent]); - - useEffect(() => { - const cEvent = getCurrentEvent(); - if (cEvent && offset >= 0) { - timelineContainerRef.current.scroll({ - left: cEvent.positionX + offset - timelineOffset, - behavior: 'smooth', - }); - } - }, [offset, timelineContainerRef]); - - useEffect(() => { - if (timeline.length > 0 && currentIndex !== undefined) { - const event = timeline[currentIndex]; - setCurrentEvent({ - ...event, - id: event.id, - index: currentIndex, - startTime: longToDate(event.start_time), - endTime: longToDate(event.end_time), - }); - timelineContainerRef.current.scroll({ left: event.positionX - timelineOffset, behavior: 'smooth' }); - } - }, [currentIndex, timelineContainerRef, timeline]); - - const checkMarkerForEvent = (markerTime) => { - if (!scrollActive) { - setScrollActive(true); - return; - } - - if (timeline) { - const foundIndex = timeline.findIndex((event) => event.startTime <= markerTime && markerTime <= event.endTime); - if (foundIndex > -1) { - const found = timeline[foundIndex]; - if (found !== currentEvent && found.id !== currentEvent.id) { - setCurrentEvent({ - ...found, - id: found.id, - index: foundIndex, - startTime: longToDate(found.start_time), - endTime: longToDate(found.end_time), - }); - return found; - } - } - } - }; - - const scrollLogic = () => { - clearTimeout(scrollTimeout); - - const scrollPosition = timelineContainerRef.current.scrollLeft; - const startTime = longToDate(timeline[0].start_time); - const markerTime = new Date(startTime.getTime() + scrollPosition * 1000); - setMarkerTime(markerTime); - - handleChange(currentEvent, markerTime, false); - - setScrollTimeout( - setTimeout(() => { - const foundEvent = checkMarkerForEvent(markerTime); - handleChange(foundEvent ? foundEvent : currentEvent, markerTime, true); - }, 250) - ); - }; - - const handleWheel = (event) => { - if (!disabled) { - return; - } - - scrollLogic(event); - }; - - const handleScroll = (event) => { - if (disabled) { - return; - } - scrollLogic(event); - }; - - useEffect(() => { - if (timelineContainerRef) { - const timelineContainerWidth = timelineContainerRef.current.offsetWidth; - const offset = Math.round(timelineContainerWidth / 2); - setTimelineOffset(offset); - } - }, [timelineContainerRef]); - - const handleChange = useCallback( - (event, time, seekComplete) => { - if (onChange !== undefined) { - onChange({ - event, - time, - seekComplete, - }); - } - }, - [onChange] - ); - - const RenderTimeline = useCallback(() => { - if (timeline && timeline.length > 0) { - const lastEvent = timeline[timeline.length - 1]; - const timelineLength = timelineOffset + lastEvent.positionX + lastEvent.width; - return ( -
- {timeline.map((e) => { - return ( -
- ); - })} -
- ); - } - }, [timeline, timelineOffset]); - - return ( -
-
-
- - {markerTime && {markerTime.toLocaleTimeString()}} - -
-
-
-
- -
-
- ); -} diff --git a/web/src/components/Timeline/Timeline.tsx b/web/src/components/Timeline/Timeline.tsx new file mode 100644 index 000000000..890fb15aa --- /dev/null +++ b/web/src/components/Timeline/Timeline.tsx @@ -0,0 +1,235 @@ +import { h } from 'preact'; +import { useCallback, useEffect, useRef, useState } from 'preact/hooks'; +import { longToDate } from '../../utils/dateUtil'; +import { TimelineBlocks } from './TimelineBlocks'; + +export interface TimelineEvent { + start_time: number; + end_time: number; + startTime: Date; + endTime: Date; + id: string; + label: string; +} + +export interface TimelineEventBlock extends TimelineEvent { + index: number; + yOffset: number; + width: number; + positionX: number; + seconds: number; +} + +interface TimelineProps { + events: TimelineEvent[]; + offset: number; + disableMarkerEvents?: boolean; + onChange: (timelineChangedEvent: any) => void; +} + +export default function Timeline({ events, offset, disableMarkerEvents, onChange }: TimelineProps) { + const timelineContainerRef = useRef(undefined); + + const [timeline, setTimeline] = useState([]); + const [timelineOffset, setTimelineOffset] = useState(undefined); + const [markerTime, setMarkerTime] = useState(undefined); + const [currentEvent, setCurrentEvent] = useState(undefined); + const [scrollTimeout, setScrollTimeout] = useState(undefined); + const [isScrollAllowed, setScrollAllowed] = useState(!disableMarkerEvents); + + useEffect(() => { + setScrollAllowed(!disableMarkerEvents); + }, [disableMarkerEvents]); + + useEffect(() => { + if (offset > 0) { + scrollToPositionInCurrentEvent(offset); + } + }, [offset]); + + const checkEventForOverlap = (firstEvent: TimelineEvent, secondEvent: TimelineEvent) => { + if (secondEvent.startTime < firstEvent.endTime) { + return true; + } + return false; + }; + + const determineOffset = (currentEvent: TimelineEventBlock, previousEvents: TimelineEventBlock[]): number => { + const OFFSET_DISTANCE_IN_PIXELS = 10; + const previousIndex = previousEvents.length - 1; + const previousEvent = previousEvents[previousIndex]; + if (previousEvent) { + const isOverlap = checkEventForOverlap(previousEvent, currentEvent); + if (isOverlap) { + return OFFSET_DISTANCE_IN_PIXELS + determineOffset(currentEvent, previousEvents.slice(0, previousIndex)); + } + } + return 0; + }; + + const buildTimelineView = (events: TimelineEvent[]): TimelineEventBlock[] => { + const firstEvent = events[0]; + const firstEventTime = longToDate(firstEvent.start_time); + return events + .map((e, index) => { + const startTime = longToDate(e.start_time); + const endTime = e.end_time ? longToDate(e.end_time) : new Date(); + const seconds = Math.round(Math.abs(endTime.getTime() - startTime.getTime()) / 1000); + const positionX = Math.round(Math.abs(startTime.getTime() - firstEventTime.getTime()) / 1000 + timelineOffset); + return { + ...e, + startTime, + endTime, + width: seconds, + positionX, + index, + } as TimelineEventBlock; + }) + .reduce((eventBlocks, current) => { + const offset = determineOffset(current, eventBlocks); + current.yOffset = offset; + return [...eventBlocks, current]; + }, [] as TimelineEventBlock[]); + }; + + useEffect(() => { + if (events && events.length > 0 && timelineOffset) { + const timelineEvents = buildTimelineView(events); + const lastEventIndex = timelineEvents.length - 1; + + setTimeline(timelineEvents); + setMarkerTime(timelineEvents[lastEventIndex].startTime); + setCurrentEvent(timelineEvents[lastEventIndex]); + } + }, [events, timelineOffset]); + + useEffect(() => { + const timelineIsLoaded = timeline.length > 0; + if (timelineIsLoaded) { + const lastEvent = timeline[timeline.length - 1]; + scrollToEvent(lastEvent); + } + }, [timeline]); + + useEffect(() => { + if (currentEvent) { + handleChange(true); + } + }, [currentEvent]); + + const disableScrollEvent = (milliseconds) => { + setScrollAllowed(false); + let timeout: NodeJS.Timeout = undefined; + timeout = setTimeout(() => { + setScrollAllowed(true); + clearTimeout(timeout); + }, milliseconds); + }; + + const scrollToPosition = (positionX: number) => { + if (timelineContainerRef.current) { + disableScrollEvent(150); + timelineContainerRef.current.scroll({ + left: positionX, + behavior: 'smooth', + }); + } + }; + + const scrollToPositionInCurrentEvent = (offset: number) => { + scrollToPosition(currentEvent.positionX + offset - timelineOffset); + setMarkerTime(getCurrentMarkerTime()); + }; + + const scrollToEvent = (event, offset = 0) => { + scrollToPosition(event.positionX + offset - timelineOffset); + }; + + const checkMarkerForEvent = (markerTime) => { + return [...timeline] + .reverse() + .find((timelineEvent) => timelineEvent.startTime <= markerTime && timelineEvent.endTime >= markerTime); + }; + + const getCurrentMarkerTime = () => { + if (timelineContainerRef.current && timeline.length > 0) { + const scrollPosition = timelineContainerRef.current.scrollLeft; + const firstTimelineEvent = timeline[0] as TimelineEventBlock; + const firstTimelineEventStartTime = firstTimelineEvent.startTime.getTime(); + return new Date(firstTimelineEventStartTime + scrollPosition * 1000); + } + }; + + const onTimelineScrollHandler = () => { + if (isScrollAllowed) { + if (timelineContainerRef.current && timeline.length > 0) { + clearTimeout(scrollTimeout); + const currentMarkerTime = getCurrentMarkerTime(); + setMarkerTime(currentMarkerTime); + handleChange(false); + + setScrollTimeout( + setTimeout(() => { + const overlappingEvent = checkMarkerForEvent(currentMarkerTime); + setCurrentEvent(overlappingEvent); + }, 150) + ); + } + } + }; + + useEffect(() => { + if (timelineContainerRef) { + const timelineContainerWidth = timelineContainerRef.current.offsetWidth; + const offset = Math.round(timelineContainerWidth / 2); + setTimelineOffset(offset); + } + }, [timelineContainerRef]); + + const handleChange = useCallback( + (seekComplete: boolean) => { + if (onChange) { + onChange({ + event: currentEvent, + markerTime, + seekComplete, + }); + } + }, + [onChange, currentEvent, markerTime] + ); + + const handleViewEvent = (event: TimelineEventBlock) => { + setCurrentEvent(event); + setMarkerTime(getCurrentMarkerTime()); + scrollToEvent(event); + }; + + return ( +
+
+ + {markerTime && {markerTime.toLocaleTimeString()}} + +
+
+
+
+
+
+
+
+ {timeline.length > 0 && ( + + )} +
+
+
+ ); +} diff --git a/web/src/components/Timeline/TimelineBlockView.tsx b/web/src/components/Timeline/TimelineBlockView.tsx new file mode 100644 index 000000000..1611d663d --- /dev/null +++ b/web/src/components/Timeline/TimelineBlockView.tsx @@ -0,0 +1,24 @@ +import { h } from 'preact'; +import { TimelineEventBlock } from './Timeline'; + +interface TimelineBlockViewProps { + block: TimelineEventBlock; + onClick: (block: TimelineEventBlock) => void; +} + +export const TimelineBlockView = ({ block, onClick }: TimelineBlockViewProps) => { + const onClickHandler = () => onClick(block); + + return ( +
+ ); +}; diff --git a/web/src/components/Timeline/TimelineBlocks.tsx b/web/src/components/Timeline/TimelineBlocks.tsx new file mode 100644 index 000000000..8d144ce89 --- /dev/null +++ b/web/src/components/Timeline/TimelineBlocks.tsx @@ -0,0 +1,39 @@ +import { h } from 'preact'; +import { TimelineEventBlock } from './Timeline'; +import { TimelineBlockView } from './TimelineBlockView'; + +interface TimelineBlocksProps { + timeline: TimelineEventBlock[]; + firstBlockOffset: number; + onEventClick: (block: TimelineEventBlock) => void; +} +export const TimelineBlocks = ({ timeline, firstBlockOffset, onEventClick }: TimelineBlocksProps) => { + const calculateTimelineContainerWidth = () => { + if (timeline.length > 0) { + const startTimeEpoch = timeline[0].startTime.getTime(); + const endTimeEpoch = new Date().getTime(); + return Math.round(Math.abs(endTimeEpoch - startTimeEpoch) / 1000) + firstBlockOffset * 2; + } + }; + + const onClickHandler = (block: TimelineEventBlock) => onEventClick(block); + + if (timeline && timeline.length > 0) { + return ( +
+ {timeline.map((block) => ( + + ))} +
+ ); + } +}; diff --git a/web/src/routes/HistoryHeader.jsx b/web/src/routes/HistoryHeader.jsx index 37b5b79f7..2ae41bf27 100644 --- a/web/src/routes/HistoryHeader.jsx +++ b/web/src/routes/HistoryHeader.jsx @@ -1,13 +1,15 @@ -import { h } from 'preact' -import Heading from '../components/Heading' +import { h } from 'preact'; +import Heading from '../components/Heading'; -export function HistoryHeader({ objectLabel, date, camera, className }) { +export function HistoryHeader({ objectLabel, date, end, camera, className }) { return (
- {objectLabel} + {objectLabel}
- Today, {date.toLocaleTimeString()} · {camera} + + Today, {date.toLocaleTimeString()} - {end ? end.toLocaleTimeString() : 'Incomplete'} · {camera} +
- ) + ); } diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 000000000..a226c8dd8 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,7 @@ +{ + "compilerOptions": { + "jsx": "preserve", + "jsxFactory": "h", + "jsxFragmentFactory": "Fragment", + } +} \ No newline at end of file