Add graph showing motion and object activity to history timeline desktop view (#9184)

* Add timeline graph component

* Add more custom colors and improve graph

* Add api and data

* Fix data sorting

* Add graph to timeline

* Only show timeline for selected hour

* Make data full range
This commit is contained in:
Nicolas Mowen
2024-01-03 17:40:14 -06:00
committed by Blake Blackshear
parent 6dd9d54f70
commit 9c4b69191b
16 changed files with 412 additions and 28 deletions

View File

@@ -83,19 +83,19 @@ export default function DynamicCameraImage({
<div className="flex absolute right-0 bottom-0 bg-black bg-opacity-20 rounded p-1">
<MdLeakAdd
className={`${
detectingMotion == "ON" ? "text-red-500" : "text-gray-600"
detectingMotion == "ON" ? "text-motion" : "text-gray-600"
}`}
/>
<TbUserScan
className={`${
activeObjects.length > 0 ? "text-cyan-500" : "text-gray-600"
activeObjects.length > 0 ? "text-object" : "text-gray-600"
}`}
/>
{camera.audio.enabled && (
<LuEar
className={`${
parseInt(audioRms) >= camera.audio.min_volume
? "text-orange-500"
? "text-audio"
: "text-gray-600"
}`}
/>

View File

@@ -59,7 +59,7 @@ export default function HistoryCard({
</div>
<Button className="px-2 py-2" variant="ghost" size="xs">
<LuTrash
className="w-5 h-5 stroke-red-500"
className="w-5 h-5 stroke-danger"
onClick={(e: Event) => {
e.stopPropagation();

View File

@@ -0,0 +1,65 @@
import { GraphData } from "@/types/graph";
import Chart from "react-apexcharts";
type TimelineGraphProps = {
id: string;
data: GraphData[];
};
/**
* A graph meant to be overlaid on top of a timeline
*/
export default function TimelineGraph({ id, data }: TimelineGraphProps) {
return (
<Chart
type="bar"
options={{
colors: ["#991b1b", "#06b6d4", "#ea580c"],
chart: {
id: id,
selection: {
enabled: false,
},
toolbar: {
show: false,
},
zoom: {
enabled: false,
},
},
dataLabels: { enabled: false },
grid: {
show: false,
},
legend: {
show: false,
position: "top",
},
tooltip: {
enabled: false,
},
xaxis: {
type: "datetime",
axisBorder: {
show: false,
},
axisTicks: {
show: false,
},
labels: {
show: false,
},
},
yaxis: {
labels: {
show: false,
},
logarithmic: true,
logBase: 10,
},
}}
series={data}
height="100%"
/>
);
}

View File

@@ -150,9 +150,9 @@ function ConfigEditor() {
</div>
</div>
{success && <div className="max-h-20 text-green-500">{success}</div>}
{success && <div className="max-h-20 text-success">{success}</div>}
{error && (
<div className="p-4 overflow-scroll text-red-500 whitespace-pre-wrap">
<div className="p-4 overflow-scroll text-danger whitespace-pre-wrap">
{error}
</div>
)}

View File

@@ -123,7 +123,7 @@ function Camera({ camera }: { camera: CameraConfig }) {
? recordValue == "ON"
? "text-primary"
: "text-gray-400"
: "text-red-500"
: "text-danger"
}
onClick={(e) => {
e.stopPropagation();

View File

@@ -160,7 +160,7 @@ function Export() {
{message.text && (
<div
className={`max-h-20 ${
message.error ? "text-red-500" : "text-green-500"
message.error ? "text-danger" : "text-success"
}`}
>
{message.text}

View File

@@ -212,7 +212,7 @@ function History() {
Cancel
</AlertDialogCancel>
<AlertDialogAction
className="bg-red-500"
className="bg-danger"
onClick={() => onDeleteMulti()}
>
Delete

9
web/src/types/graph.ts Normal file
View File

@@ -0,0 +1,9 @@
export type GraphDataPoint = {
x: Date;
y: number;
};
export type GraphData = {
name?: string;
data: GraphDataPoint[];
};

View File

@@ -56,6 +56,12 @@ interface HistoryFilter extends FilterType {
detailLevel: "normal" | "extra" | "full";
}
type HistoryTimeline = {
start: number;
end: number;
playbackItems: TimelinePlayback[];
};
type TimelinePlayback = {
camera: string;
range: { start: number; end: number };

View File

@@ -1,11 +1,30 @@
type Recording = {
id: string,
camera: string,
start_time: number,
end_time: number,
path: string,
segment_size: number,
motion: number,
objects: number,
dBFS: number,
}
id: string;
camera: string;
start_time: number;
end_time: number;
path: string;
segment_size: number;
motion: number;
objects: number;
dBFS: number;
};
type RecordingSegment = {
id: string;
start_time: number;
end_time: number;
motion: number;
objects: number;
segment_size: number;
};
type RecordingActivity = {
[hour: number]: RecordingSegmentActivity[];
};
type RecordingSegmentActivity = {
date: number;
count: number;
type: "motion" | "objects";
};

View File

@@ -107,13 +107,14 @@ export function getTimelineHoursForDay(
cards: CardsData,
allPreviews: Preview[],
timestamp: number
): TimelinePlayback[] {
): HistoryTimeline {
const now = new Date();
const data: TimelinePlayback[] = [];
const startDay = new Date(timestamp * 1000);
startDay.setHours(23, 59, 59, 999);
const dayEnd = startDay.getTime() / 1000;
startDay.setHours(0, 0, 0, 0);
const startTimestamp = startDay.getTime() / 1000;
let start = startDay.getTime() / 1000;
let end = 0;
@@ -134,7 +135,7 @@ export function getTimelineHoursForDay(
});
if (dayIdx == undefined) {
return [];
return { start: 0, end: 0, playbackItems: [] };
}
const day = cards[dayIdx];
@@ -179,5 +180,5 @@ export function getTimelineHoursForDay(
start = startDay.getTime() / 1000;
}
return data.reverse();
return { start: startTimestamp, end, playbackItems: data.reverse() };
}

View File

@@ -10,6 +10,8 @@ import useSWR from "swr";
import Player from "video.js/dist/types/player";
import TimelineItemCard from "@/components/card/TimelineItemCard";
import { getTimelineHoursForDay } from "@/utils/historyUtil";
import { GraphDataPoint } from "@/types/graph";
import TimelineGraph from "@/components/graph/TimelineGraph";
type DesktopTimelineViewProps = {
timelineData: CardsData;
@@ -166,6 +168,50 @@ export default function DesktopTimelineView({
[]
);
const { data: activity } = useSWR<RecordingActivity>(
[
`${initialPlayback.camera}/recording/hourly/activity`,
{
after: timelineStack.start,
before: timelineStack.end,
timezone,
},
],
{ revalidateOnFocus: false }
);
const timelineGraphData = useMemo(() => {
if (!activity) {
return {};
}
const graphData: {
[hour: string]: { objects: GraphDataPoint[]; motion: GraphDataPoint[] };
} = {};
Object.entries(activity).forEach(([hour, data]) => {
const objects: GraphDataPoint[] = [];
const motion: GraphDataPoint[] = [];
data.forEach((seg) => {
if (seg.type == "objects") {
objects.push({
x: new Date(seg.date * 1000),
y: seg.count,
});
} else {
motion.push({
x: new Date(seg.date * 1000),
y: seg.count,
});
}
});
graphData[hour] = { objects, motion };
});
return graphData;
}, [activity]);
if (!config) {
return <ActivityIndicator />;
}
@@ -271,14 +317,17 @@ export default function DesktopTimelineView({
</div>
</div>
<div className="m-1 max-h-72 2xl:max-h-80 3xl:max-h-96 overflow-auto">
{timelineStack.map((timeline) => {
{timelineStack.playbackItems.map((timeline) => {
const isSelected =
timeline.range.start == selectedPlayback.range.start;
const graphData = timelineGraphData[timeline.range.start];
return (
<div
key={timeline.range.start}
className={`p-2 ${isSelected ? "bg-secondary bg-opacity-30 rounded-md" : ""}`}
className={`relative p-2 ${
isSelected ? "bg-secondary bg-opacity-30 rounded-md" : ""
}`}
>
<ActivityScrubber
items={[]}
@@ -324,9 +373,6 @@ export default function DesktopTimelineView({
setScrubbing(false);
playerRef.current?.play();
}}
doubleClickHandler={() => {
setSelectedPlayback(timeline);
}}
selectHandler={(data) => {
if (data.items.length > 0) {
const selected = data.items[0];
@@ -337,7 +383,22 @@ export default function DesktopTimelineView({
);
}
}}
doubleClickHandler={() => setSelectedPlayback(timeline)}
/>
{isSelected && graphData && (
<div className="w-full absolute left-0 top-0 h-[84px]">
<TimelineGraph
id={timeline.range.start.toString()}
data={[
{
name: "Motion",
data: graphData.motion,
},
{ name: "Active Objects", data: graphData.objects },
]}
/>
</div>
)}
</div>
);
})}