forked from Github/frigate
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:
committed by
Blake Blackshear
parent
6dd9d54f70
commit
9c4b69191b
@@ -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"
|
||||
}`}
|
||||
/>
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
65
web/src/components/graph/TimelineGraph.tsx
Normal file
65
web/src/components/graph/TimelineGraph.tsx
Normal 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%"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
9
web/src/types/graph.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export type GraphDataPoint = {
|
||||
x: Date;
|
||||
y: number;
|
||||
};
|
||||
|
||||
export type GraphData = {
|
||||
name?: string;
|
||||
data: GraphDataPoint[];
|
||||
};
|
||||
@@ -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 };
|
||||
|
||||
@@ -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";
|
||||
};
|
||||
|
||||
@@ -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() };
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user