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