timeline scrubber and revamp for all event handlers

This commit is contained in:
Josh Hawkins 2023-12-20 17:07:09 -06:00
parent 9167130896
commit 3c498fc087
4 changed files with 227 additions and 85 deletions

View File

@ -0,0 +1,46 @@
import useSWR from "swr";
import ActivityScrubber, { ScrubberItem } from "../scrubber/ActivityScrubber";
type TimelineScrubberProps = {
eventID: string;
};
function timelineEventsToScrubberItems(events: Timeline[]): ScrubberItem[] {
return events.map((event: Timeline, index: number) => ({
id: index,
content: event.class_type,
start: event.timestamp * 1000,
type: "box",
}));
}
function generateScrubberOptions(events: Timeline[]) {
const startTime = events[0].timestamp * 1000 - 10;
const endTime = events[events.length - 1].timestamp * 1000 + 10;
return { start: startTime, end: endTime };
}
function TimelineScrubber({ eventID }: TimelineScrubberProps) {
const { data: eventTimeline } = useSWR<Timeline[]>([
"timeline",
{
source_id: eventID,
},
]);
return (
<>
{eventTimeline && (
<>
<ActivityScrubber
items={timelineEventsToScrubberItems(eventTimeline)}
options={generateScrubberOptions(eventTimeline)}
/>
</>
)}
</>
);
}
export default TimelineScrubber;

View File

@ -1,39 +1,116 @@
import { useRef, useEffect, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { Timeline } from "vis-timeline"; import {
import { TimelineOptions } from "vis-timeline/standalone"; Timeline as VisTimeline,
TimelineGroup,
TimelineItem,
TimelineOptions,
} from "vis-timeline";
import type { DataGroup, DataItem, TimelineEvents } from "vis-timeline/types";
import "./scrubber.css"; import "./scrubber.css";
export type ScrubberItem = { export type TimelineEventsWithMissing =
id: string; | TimelineEvents
content: string; | "dragover"
start: Date; | "markerchange"
end?: Date; | "markerchanged";
type?: "box" | "point";
export type TimelineEventHandler =
| "currentTimeTickHandler"
| "clickHandler"
| "contextmenuHandler"
| "doubleClickHandler"
| "dragoverHandler"
| "dropHandler"
| "mouseOverHandler"
| "mouseDownHandler"
| "mouseUpHandler"
| "mouseMoveHandler"
| "groupDraggedHandler"
| "changedHandler"
| "rangechangeHandler"
| "rangechangedHandler"
| "selectHandler"
| "itemoverHandler"
| "itemoutHandler"
| "timechangeHandler"
| "timechangedHandler"
| "markerchangeHandler"
| "markerchangedHandler";
type EventHandler = {
(properties: any): void;
}; };
export type ScrubberSelectProps = { export type TimelineEventsHandlers = Partial<
nodes: ScrubberItem[]; Record<TimelineEventHandler, EventHandler>
}; >;
type ScrubberChartProps = { export type ScrubberItem = TimelineItem;
items: ScrubberItem[];
const domEvents: TimelineEventsWithMissing[] = [
"currentTimeTick",
"click",
"contextmenu",
"doubleClick",
"dragover",
"drop",
"mouseOver",
"mouseDown",
"mouseUp",
"mouseMove",
"groupDragged",
"changed",
"rangechange",
"rangechanged",
"select",
"itemover",
"itemout",
"timechange",
"timechanged",
"markerchange",
"markerchanged",
];
type ActivityScrubberProps = {
items: TimelineItem[];
groups?: TimelineGroup[];
options?: TimelineOptions; options?: TimelineOptions;
onSelect?: (props: ScrubberSelectProps) => void; } & TimelineEventsHandlers;
};
export function ActivityScrubber({ function ActivityScrubber({
items, items,
groups,
options, options,
onSelect, ...eventHandlers
}: ScrubberChartProps) { }: ActivityScrubberProps) {
const container = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const timelineRef = useRef<Timeline | null>(null); const timelineRef = useRef<{ timeline: VisTimeline | null }>({
timeline: null,
});
const [currentTime, setCurrentTime] = useState(Date.now()); const [currentTime, setCurrentTime] = useState(Date.now());
const defaultOptions: TimelineOptions = {
width: "100%",
maxHeight: "350px",
stack: true,
showMajorLabels: true,
showCurrentTime: false,
zoomMin: 10 * 1000, // 10 seconds
// start: new Date(currentTime - 60 * 1 * 60 * 1000), // 1 hour ago
end: currentTime,
max: currentTime,
format: {
minorLabels: {
minute: "h:mma",
hour: "ha",
},
},
};
useEffect(() => { useEffect(() => {
const intervalId = setInterval(() => { const intervalId = setInterval(() => {
setCurrentTime(Date.now()); setCurrentTime(Date.now());
}, 10000); }, 60000); // Update every minute
return () => { return () => {
clearInterval(intervalId); clearInterval(intervalId);
@ -41,54 +118,63 @@ export function ActivityScrubber({
}, []); }, []);
useEffect(() => { useEffect(() => {
const defaultOptions: TimelineOptions = { const divElement = containerRef.current;
width: "100%", if (!divElement) {
stack: true, return;
showMajorLabels: true, }
showCurrentTime: true,
zoomMin: 10 * 1000, // 10 seconds const timelineInstance = new VisTimeline(
end: currentTime, divElement,
max: currentTime, items as DataItem[],
format: { groups as DataGroup[],
minorLabels: { options
minute: "h:mma", );
hour: "ha",
}, domEvents.forEach((event) => {
}, const eventHandler = eventHandlers[`${event}Handler`];
}; if (typeof eventHandler === "function") {
timelineInstance.on(event, eventHandler);
}
});
timelineRef.current.timeline = timelineInstance;
const timelineOptions: TimelineOptions = { const timelineOptions: TimelineOptions = {
...defaultOptions, ...defaultOptions,
...options, ...options,
}; };
if (!timelineRef.current) { timelineInstance.setOptions(timelineOptions);
timelineRef.current = new Timeline(
container.current as HTMLDivElement,
items,
timelineOptions
);
const updateRange = () => { return () => {
timelineRef.current?.setOptions(timelineOptions); timelineInstance.destroy();
}; };
}, []);
timelineRef.current.on("rangechanged", updateRange); useEffect(() => {
if (onSelect) { if (!timelineRef.current.timeline) {
timelineRef.current.on("select", onSelect); return;
}
return () => {
timelineRef.current?.off("rangechanged", updateRange);
};
} else {
// Update existing timeline
timelineRef.current.setItems(items);
timelineRef.current.setOptions(timelineOptions);
} }
}, [items, options, currentTime]);
return <div ref={container}></div>; // If the currentTime updates, adjust the scrubber's end date and max
// May not be applicable to all scrubbers, might want to just pass this in
// for any scrubbers that we want to dynamically move based on time
// const updatedTimeOptions: TimelineOptions = {
// end: currentTime,
// max: currentTime,
// };
const timelineOptions: TimelineOptions = {
...defaultOptions,
// ...updatedTimeOptions,
...options,
};
timelineRef.current.timeline.setOptions(timelineOptions);
if (items) timelineRef.current.timeline.setItems(items);
}, [items, groups, options, currentTime, eventHandlers]);
return <div ref={containerRef} />;
} }
export default ActivityScrubber; export default ActivityScrubber;

View File

@ -14,10 +14,10 @@
@apply absolute invisible mx-0 px-0; @apply absolute invisible mx-0 px-0;
} }
.vis-time-axis .vis-grid.vis-vertical { .vis-time-axis .vis-grid.vis-vertical {
@apply absolute border-l border-solid; @apply absolute border-l border-dashed border-muted-foreground;
} }
.vis-time-axis .vis-grid.vis-vertical-rtl { .vis-time-axis .vis-grid.vis-vertical-rtl {
@apply absolute border-r border-solid; @apply absolute border-r border-dashed border-muted-foreground;
} }
.vis-time-axis .vis-grid.vis-minor { .vis-time-axis .vis-grid.vis-minor {
@apply border-foreground; @apply border-foreground;
@ -170,16 +170,16 @@
@apply min-h-0 w-auto; @apply min-h-0 w-auto;
} }
.vis-item { .vis-item {
@apply bg-[#d5ddf6] border text-[#1a1a1a] inline-block absolute z-[1] border-[#97b0f8]; @apply bg-accent border text-foreground inline-block absolute z-[1] border-border;
} }
.vis-item.vis-selected { .vis-item.vis-selected {
@apply bg-[#fff785] z-[2] border-[#ffc200]; @apply bg-muted-foreground z-[2] border-muted text-muted;
} }
.vis-editable.vis-selected { .vis-editable.vis-selected {
@apply cursor-move; @apply cursor-move;
} }
.vis-item.vis-point.vis-selected { .vis-item.vis-point.vis-selected {
@apply bg-[#fff785]; @apply bg-muted-foreground;
} }
.vis-item.vis-box { .vis-item.vis-box {
@apply text-center rounded-sm border-solid; @apply text-center rounded-sm border-solid;
@ -209,8 +209,7 @@
@apply inline-block absolute; @apply inline-block absolute;
} }
.vis-item.vis-line { .vis-item.vis-line {
@apply absolute w-0 p-0 border-l; @apply absolute w-0 p-0 border-l border-solid text-muted;
border-left-style: solid;
} }
.vis-item .vis-item-content { .vis-item .vis-item-content {
@apply box-border whitespace-nowrap p-[5px]; @apply box-border whitespace-nowrap p-[5px];

View File

@ -1,14 +1,14 @@
import { useMemo } from "react"; import { useCallback, useMemo, useState } from "react";
import Heading from "@/components/ui/heading"; import Heading from "@/components/ui/heading";
import { import ActivityScrubber, {
ScrubberSelectProps,
ActivityScrubber,
ScrubberItem, ScrubberItem,
} from "@/components/scrubber/ActivityScrubber"; } from "@/components/scrubber/ActivityScrubber";
import useSWR from "swr"; import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig"; import { FrigateConfig } from "@/types/frigateConfig";
import { Event } from "@/types/event"; import { Event } from "@/types/event";
import ActivityIndicator from "@/components/ui/activity-indicator"; import ActivityIndicator from "@/components/ui/activity-indicator";
import { useApiHost } from "@/api";
import TimelineScrubber from "@/components/playground/TimelineScrubber";
// Color data // Color data
const colors = [ const colors = [
@ -45,24 +45,25 @@ function ColorSwatch({ name, value }: { name: string; value: string }) {
); );
} }
function onSelect(props: ScrubberSelectProps) {
console.log(props);
}
function eventsToScrubberItems(events: Event[]): ScrubberItem[] { function eventsToScrubberItems(events: Event[]): ScrubberItem[] {
return events.map((event) => { const apiHost = useApiHost();
return {
id: event.id, return events.map((event: Event) => ({
content: event.label, id: event.id,
start: new Date(event.start_time * 1000), content: `<div class="flex"><img class="" src="${apiHost}api/events/${event.id}/thumbnail.jpg" /><span>${event.label}</span></div>`,
end: event.end_time ? new Date(event.end_time * 1000) : undefined, start: new Date(event.start_time * 1000),
type: "box", end: event.end_time ? new Date(event.end_time * 1000) : undefined,
}; type: "box",
}); }));
} }
function UIPlayground() { function UIPlayground() {
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const [timeline, setTimeline] = useState<string | undefined>(undefined);
const onSelect = useCallback(({ items }: { items: string[] }) => {
setTimeline(items[0]);
}, []);
const recentTimestamp = useMemo(() => { const recentTimestamp = useMemo(() => {
const now = new Date(); const now = new Date();
@ -93,13 +94,23 @@ function UIPlayground() {
<> <>
<ActivityScrubber <ActivityScrubber
items={eventsToScrubberItems(events)} items={eventsToScrubberItems(events)}
onSelect={onSelect} selectHandler={onSelect}
/> />
</> </>
)} )}
</div> </div>
)} )}
{config && (
<div>
{timeline && (
<>
<TimelineScrubber eventID={timeline} />
</>
)}
</div>
)}
<Heading as="h4" className="my-5"> <Heading as="h4" className="my-5">
Color scheme Color scheme
</Heading> </Heading>