refactor: rewrote logic for building timeline to support stacking and changed to typescript

This commit is contained in:
JohnMark Sill 2022-02-11 17:24:50 -06:00
parent 7401789b3b
commit ddf7065379
7 changed files with 318 additions and 230 deletions

View File

@ -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 }) {
<div className='relative flex flex-col'>
<HistoryHeader
camera={camera}
date={longToDate(currentEvent.start_time)}
date={currentEvent.startTime}
end={currentEvent.endTime}
objectLabel={currentEvent.label}
className='mb-2'
/>
@ -156,7 +154,7 @@ export default function HistoryViewer({ camera }) {
events={timelineEvents}
offset={timelineOffset}
currentIndex={currentEventIndex}
disabled={isPlaying}
disableMarkerEvents={isPlaying}
onChange={handleTimelineChange}
/>

View File

@ -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 (
<div
className='relative flex items-center h-20'
style={{
width: `${timelineLength}px`,
background: "url('/marker.png')",
backgroundPosition: 'center',
backgroundSize: '30px',
backgroundRepeat: 'repeat-x',
}}
>
{timeline.map((e) => {
return (
<div
key={e.id}
className='absolute z-10 rounded-full bg-blue-300 h-2'
style={{
left: `${e.positionX}px`,
width: `${e.seconds}px`,
}}
></div>
);
})}
</div>
);
}
}, [timeline, timelineOffset]);
return (
<div className='relative flex-grow-1'>
<div className='absolute left-0 top-0 h-full w-full' style={{ textAlign: 'center' }}>
<div className='h-full' style={{ margin: '0 auto', textAlign: 'center' }}>
<span className='z-20 text-black dark:text-white'>
{markerTime && <span>{markerTime.toLocaleTimeString()}</span>}
</span>
<div
className='z-20 h-full absolute'
style={{
left: 'calc(100% / 2)',
height: 'calc(100% - 24px)',
borderRight: '2px solid rgba(252, 211, 77)',
}}
></div>
</div>
</div>
<div
ref={timelineContainerRef}
onWheel={handleWheel}
onTouchMove={handleWheel}
onScroll={handleScroll}
className='overflow-x-auto hide-scroll'
>
<RenderTimeline />
</div>
</div>
);
}

View File

@ -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<HTMLDivElement>(undefined);
const [timeline, setTimeline] = useState<TimelineEventBlock[]>([]);
const [timelineOffset, setTimelineOffset] = useState<number | undefined>(undefined);
const [markerTime, setMarkerTime] = useState<Date | undefined>(undefined);
const [currentEvent, setCurrentEvent] = useState<TimelineEventBlock | undefined>(undefined);
const [scrollTimeout, setScrollTimeout] = useState<NodeJS.Timeout | undefined>(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 (
<div className='flex-grow-1'>
<div className='w-full text-center'>
<span className='text-black dark:text-white'>
{markerTime && <span>{markerTime.toLocaleTimeString()}</span>}
</span>
</div>
<div className='relative'>
<div className='absolute left-0 top-0 h-full w-full text-center'>
<div className='h-full text-center' style={{ margin: '0 auto' }}>
<div
className='z-20 h-full absolute'
style={{
left: 'calc(100% / 2)',
borderRight: '2px solid rgba(252, 211, 77)',
}}
></div>
</div>
</div>
<div ref={timelineContainerRef} onScroll={onTimelineScrollHandler} className='overflow-x-auto hide-scroll'>
{timeline.length > 0 && (
<TimelineBlocks timeline={timeline} firstBlockOffset={timelineOffset} onEventClick={handleViewEvent} />
)}
</div>
</div>
</div>
);
}

View File

@ -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 (
<div
key={block.id}
onClick={onClickHandler}
className='absolute z-10 rounded-full bg-blue-300 h-2'
style={{
top: `${block.yOffset}px`,
left: `${block.positionX}px`,
width: `${block.width}px`,
}}
></div>
);
};

View File

@ -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 (
<div
className='relative block whitespace-nowrap overflow-x-hidden h-16'
style={{
width: `${calculateTimelineContainerWidth()}px`,
background: "url('/marker.png')",
backgroundPosition: 'center',
backgroundSize: '30px',
backgroundRepeat: 'repeat',
}}
>
{timeline.map((block) => (
<TimelineBlockView block={block} onClick={onClickHandler} />
))}
</div>
);
}
};

View File

@ -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 (
<div className={`text-center ${className}`}>
<Heading size="lg">{objectLabel}</Heading>
<Heading size='lg'>{objectLabel}</Heading>
<div>
<span>Today, {date.toLocaleTimeString()} &middot; {camera}</span>
<span>
Today, {date.toLocaleTimeString()} - {end ? end.toLocaleTimeString() : 'Incomplete'} &middot; {camera}
</span>
</div>
</div>
)
);
}

7
web/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"compilerOptions": {
"jsx": "preserve",
"jsxFactory": "h",
"jsxFragmentFactory": "Fragment",
}
}