mirror of
https://github.com/blakeblackshear/frigate.git
synced 2025-07-30 13:48:07 +02:00
Reorganize Lifecycle components (#16663)
* reorganize lifecycle components * clean up
This commit is contained in:
parent
4f88a5f2ad
commit
1d3de77f63
@ -11,7 +11,7 @@ import {
|
|||||||
CarouselPrevious,
|
CarouselPrevious,
|
||||||
} from "@/components/ui/carousel";
|
} from "@/components/ui/carousel";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { ClassType, ObjectLifecycleSequence } from "@/types/timeline";
|
import { ObjectLifecycleSequence } from "@/types/timeline";
|
||||||
import Heading from "@/components/ui/heading";
|
import Heading from "@/components/ui/heading";
|
||||||
import { ReviewDetailPaneType } from "@/types/review";
|
import { ReviewDetailPaneType } from "@/types/review";
|
||||||
import { FrigateConfig } from "@/types/frigateConfig";
|
import { FrigateConfig } from "@/types/frigateConfig";
|
||||||
@ -52,13 +52,8 @@ import {
|
|||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@/components/ui/context-menu";
|
} from "@/components/ui/context-menu";
|
||||||
import { useNavigate } from "react-router-dom";
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import { ObjectPath } from "./ObjectPath";
|
||||||
type Position = {
|
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
timestamp: number;
|
|
||||||
lifecycle_item?: ObjectLifecycleSequence;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ObjectLifecycleProps = {
|
type ObjectLifecycleProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -400,6 +395,8 @@ export default function ObjectLifecycle({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{showZones &&
|
{showZones &&
|
||||||
|
imgRef.current?.width &&
|
||||||
|
imgRef.current?.height &&
|
||||||
lifecycleZones?.map((zone) => (
|
lifecycleZones?.map((zone) => (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 flex items-center justify-center"
|
className="absolute inset-0 flex items-center justify-center"
|
||||||
@ -434,7 +431,10 @@ export default function ObjectLifecycle({
|
|||||||
<div className="absolute bottom-[-3px] left-1/2 h-[5px] w-[5px] -translate-x-1/2 transform bg-yellow-500" />
|
<div className="absolute bottom-[-3px] left-1/2 h-[5px] w-[5px] -translate-x-1/2 transform bg-yellow-500" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{pathPoints && pathPoints.length > 0 && (
|
{imgRef.current?.width &&
|
||||||
|
imgRef.current?.height &&
|
||||||
|
pathPoints &&
|
||||||
|
pathPoints.length > 0 && (
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 flex items-center justify-center"
|
className="absolute inset-0 flex items-center justify-center"
|
||||||
style={{
|
style={{
|
||||||
@ -448,7 +448,7 @@ export default function ObjectLifecycle({
|
|||||||
className="absolute inset-0"
|
className="absolute inset-0"
|
||||||
>
|
>
|
||||||
<ObjectPath
|
<ObjectPath
|
||||||
positions={pathPoints} // Use any of the example paths
|
positions={pathPoints}
|
||||||
color={getObjectColor(event.label)}
|
color={getObjectColor(event.label)}
|
||||||
width={2}
|
width={2}
|
||||||
imgRef={imgRef}
|
imgRef={imgRef}
|
||||||
@ -755,149 +755,3 @@ export function LifecycleIcon({
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLifecycleItemDescription(lifecycleItem: ObjectLifecycleSequence) {
|
|
||||||
const label = (
|
|
||||||
(Array.isArray(lifecycleItem.data.sub_label)
|
|
||||||
? lifecycleItem.data.sub_label[0]
|
|
||||||
: lifecycleItem.data.sub_label) || lifecycleItem.data.label
|
|
||||||
).replaceAll("_", " ");
|
|
||||||
|
|
||||||
switch (lifecycleItem.class_type) {
|
|
||||||
case "visible":
|
|
||||||
return `${label} detected`;
|
|
||||||
case "entered_zone":
|
|
||||||
return `${label} entered ${lifecycleItem.data.zones
|
|
||||||
.join(" and ")
|
|
||||||
.replaceAll("_", " ")}`;
|
|
||||||
case "active":
|
|
||||||
return `${label} became active`;
|
|
||||||
case "stationary":
|
|
||||||
return `${label} became stationary`;
|
|
||||||
case "attribute": {
|
|
||||||
let title = "";
|
|
||||||
if (
|
|
||||||
lifecycleItem.data.attribute == "face" ||
|
|
||||||
lifecycleItem.data.attribute == "license_plate"
|
|
||||||
) {
|
|
||||||
title = `${lifecycleItem.data.attribute.replaceAll(
|
|
||||||
"_",
|
|
||||||
" ",
|
|
||||||
)} detected for ${label}`;
|
|
||||||
} else {
|
|
||||||
title = `${
|
|
||||||
lifecycleItem.data.label
|
|
||||||
} recognized as ${lifecycleItem.data.attribute.replaceAll("_", " ")}`;
|
|
||||||
}
|
|
||||||
return title;
|
|
||||||
}
|
|
||||||
case "gone":
|
|
||||||
return `${label} left`;
|
|
||||||
case "heard":
|
|
||||||
return `${label} heard`;
|
|
||||||
case "external":
|
|
||||||
return `${label} detected`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type ObjectPathProps = {
|
|
||||||
positions?: Position[];
|
|
||||||
color?: number[];
|
|
||||||
width?: number;
|
|
||||||
pointRadius?: number;
|
|
||||||
imgRef: React.RefObject<HTMLImageElement>;
|
|
||||||
onPointClick?: (index: number) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
const typeColorMap: Partial<Record<ClassType, [number, number, number]>> = {
|
|
||||||
[ClassType.VISIBLE]: [0, 255, 0], // Green
|
|
||||||
[ClassType.GONE]: [255, 0, 0], // Red
|
|
||||||
[ClassType.ENTERED_ZONE]: [255, 165, 0], // Orange
|
|
||||||
[ClassType.ATTRIBUTE]: [128, 0, 128], // Purple
|
|
||||||
[ClassType.ACTIVE]: [255, 255, 0], // Yellow
|
|
||||||
[ClassType.STATIONARY]: [128, 128, 128], // Gray
|
|
||||||
[ClassType.HEARD]: [0, 255, 255], // Cyan
|
|
||||||
[ClassType.EXTERNAL]: [165, 42, 42], // Brown
|
|
||||||
};
|
|
||||||
|
|
||||||
function ObjectPath({
|
|
||||||
positions,
|
|
||||||
color = [0, 0, 255],
|
|
||||||
width = 2,
|
|
||||||
pointRadius = 4,
|
|
||||||
imgRef,
|
|
||||||
onPointClick,
|
|
||||||
}: ObjectPathProps) {
|
|
||||||
const getAbsolutePositions = useCallback(() => {
|
|
||||||
if (!imgRef.current || !positions) return [];
|
|
||||||
const imgRect = imgRef.current.getBoundingClientRect();
|
|
||||||
return positions.map((pos) => ({
|
|
||||||
x: pos.x * imgRect.width,
|
|
||||||
y: pos.y * imgRect.height,
|
|
||||||
timestamp: pos.timestamp,
|
|
||||||
lifecycle_item: pos.lifecycle_item,
|
|
||||||
}));
|
|
||||||
}, [positions, imgRef]);
|
|
||||||
|
|
||||||
const generateStraightPath = useCallback((points: Position[]) => {
|
|
||||||
if (!points || points.length < 2) return "";
|
|
||||||
let path = `M ${points[0].x} ${points[0].y}`;
|
|
||||||
for (let i = 1; i < points.length; i++) {
|
|
||||||
path += ` L ${points[i].x} ${points[i].y}`;
|
|
||||||
}
|
|
||||||
return path;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const getPointColor = (baseColor: number[], type?: ClassType) => {
|
|
||||||
if (type) {
|
|
||||||
const typeColor = typeColorMap[type];
|
|
||||||
if (typeColor) {
|
|
||||||
return `rgb(${typeColor.join(",")})`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// normal path point
|
|
||||||
return `rgb(${baseColor.map((c) => Math.max(0, c - 10)).join(",")})`;
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!imgRef.current) return null;
|
|
||||||
const absolutePositions = getAbsolutePositions();
|
|
||||||
const lineColor = `rgb(${color.join(",")})`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<g>
|
|
||||||
<path
|
|
||||||
d={generateStraightPath(absolutePositions)}
|
|
||||||
fill="none"
|
|
||||||
stroke={lineColor}
|
|
||||||
strokeWidth={width}
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
/>
|
|
||||||
{absolutePositions.map((pos, index) => (
|
|
||||||
<Tooltip key={`point-${index}`}>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<circle
|
|
||||||
cx={pos.x}
|
|
||||||
cy={pos.y}
|
|
||||||
r={pointRadius}
|
|
||||||
fill={getPointColor(color, pos.lifecycle_item?.class_type)}
|
|
||||||
stroke="white"
|
|
||||||
strokeWidth={width / 2}
|
|
||||||
onClick={() =>
|
|
||||||
pos.lifecycle_item && onPointClick && onPointClick(index)
|
|
||||||
}
|
|
||||||
style={{ cursor: pos.lifecycle_item ? "pointer" : "default" }}
|
|
||||||
/>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipPortal>
|
|
||||||
<TooltipContent side="top" className="capitalize">
|
|
||||||
{pos.lifecycle_item
|
|
||||||
? getLifecycleItemDescription(pos.lifecycle_item)
|
|
||||||
: "Tracked point"}
|
|
||||||
</TooltipContent>
|
|
||||||
</TooltipPortal>
|
|
||||||
</Tooltip>
|
|
||||||
))}
|
|
||||||
</g>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
113
web/src/components/overlay/detail/ObjectPath.tsx
Normal file
113
web/src/components/overlay/detail/ObjectPath.tsx
Normal file
@ -0,0 +1,113 @@
|
|||||||
|
import { useCallback } from "react";
|
||||||
|
import { LifecycleClassType, Position } from "@/types/timeline";
|
||||||
|
import {
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@/components/ui/tooltip";
|
||||||
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||||
|
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
||||||
|
|
||||||
|
type ObjectPathProps = {
|
||||||
|
positions?: Position[];
|
||||||
|
color?: number[];
|
||||||
|
width?: number;
|
||||||
|
pointRadius?: number;
|
||||||
|
imgRef: React.RefObject<HTMLImageElement>;
|
||||||
|
onPointClick?: (index: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeColorMap: Partial<
|
||||||
|
Record<LifecycleClassType, [number, number, number]>
|
||||||
|
> = {
|
||||||
|
[LifecycleClassType.VISIBLE]: [0, 255, 0], // Green
|
||||||
|
[LifecycleClassType.GONE]: [255, 0, 0], // Red
|
||||||
|
[LifecycleClassType.ENTERED_ZONE]: [255, 165, 0], // Orange
|
||||||
|
[LifecycleClassType.ATTRIBUTE]: [128, 0, 128], // Purple
|
||||||
|
[LifecycleClassType.ACTIVE]: [255, 255, 0], // Yellow
|
||||||
|
[LifecycleClassType.STATIONARY]: [128, 128, 128], // Gray
|
||||||
|
[LifecycleClassType.HEARD]: [0, 255, 255], // Cyan
|
||||||
|
[LifecycleClassType.EXTERNAL]: [165, 42, 42], // Brown
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ObjectPath({
|
||||||
|
positions,
|
||||||
|
color = [0, 0, 255],
|
||||||
|
width = 2,
|
||||||
|
pointRadius = 4,
|
||||||
|
imgRef,
|
||||||
|
onPointClick,
|
||||||
|
}: ObjectPathProps) {
|
||||||
|
const getAbsolutePositions = useCallback(() => {
|
||||||
|
if (!imgRef.current || !positions) return [];
|
||||||
|
const imgRect = imgRef.current.getBoundingClientRect();
|
||||||
|
return positions.map((pos) => ({
|
||||||
|
x: pos.x * imgRect.width,
|
||||||
|
y: pos.y * imgRect.height,
|
||||||
|
timestamp: pos.timestamp,
|
||||||
|
lifecycle_item: pos.lifecycle_item,
|
||||||
|
}));
|
||||||
|
}, [positions, imgRef]);
|
||||||
|
|
||||||
|
const generateStraightPath = useCallback((points: Position[]) => {
|
||||||
|
if (!points || points.length < 2) return "";
|
||||||
|
let path = `M ${points[0].x} ${points[0].y}`;
|
||||||
|
for (let i = 1; i < points.length; i++) {
|
||||||
|
path += ` L ${points[i].x} ${points[i].y}`;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getPointColor = (baseColor: number[], type?: LifecycleClassType) => {
|
||||||
|
if (type) {
|
||||||
|
const typeColor = typeColorMap[type];
|
||||||
|
if (typeColor) {
|
||||||
|
return `rgb(${typeColor.join(",")})`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// normal path point
|
||||||
|
return `rgb(${baseColor.map((c) => Math.max(0, c - 10)).join(",")})`;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!imgRef.current) return null;
|
||||||
|
const absolutePositions = getAbsolutePositions();
|
||||||
|
const lineColor = `rgb(${color.join(",")})`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<g>
|
||||||
|
<path
|
||||||
|
d={generateStraightPath(absolutePositions)}
|
||||||
|
fill="none"
|
||||||
|
stroke={lineColor}
|
||||||
|
strokeWidth={width}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
{absolutePositions.map((pos, index) => (
|
||||||
|
<Tooltip key={`point-${index}`}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<circle
|
||||||
|
cx={pos.x}
|
||||||
|
cy={pos.y}
|
||||||
|
r={pointRadius}
|
||||||
|
fill={getPointColor(color, pos.lifecycle_item?.class_type)}
|
||||||
|
stroke="white"
|
||||||
|
strokeWidth={width / 2}
|
||||||
|
onClick={() =>
|
||||||
|
pos.lifecycle_item && onPointClick && onPointClick(index)
|
||||||
|
}
|
||||||
|
style={{ cursor: pos.lifecycle_item ? "pointer" : "default" }}
|
||||||
|
/>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipPortal>
|
||||||
|
<TooltipContent side="top" className="capitalize">
|
||||||
|
{pos.lifecycle_item
|
||||||
|
? getLifecycleItemDescription(pos.lifecycle_item)
|
||||||
|
: "Tracked point"}
|
||||||
|
</TooltipContent>
|
||||||
|
</TooltipPortal>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
}
|
@ -1,4 +1,4 @@
|
|||||||
export enum ClassType {
|
export enum LifecycleClassType {
|
||||||
VISIBLE = "visible",
|
VISIBLE = "visible",
|
||||||
GONE = "gone",
|
GONE = "gone",
|
||||||
ENTERED_ZONE = "entered_zone",
|
ENTERED_ZONE = "entered_zone",
|
||||||
@ -22,7 +22,7 @@ export type ObjectLifecycleSequence = {
|
|||||||
attribute: string;
|
attribute: string;
|
||||||
zones: string[];
|
zones: string[];
|
||||||
};
|
};
|
||||||
class_type: ClassType;
|
class_type: LifecycleClassType;
|
||||||
source_id: string;
|
source_id: string;
|
||||||
source: string;
|
source: string;
|
||||||
};
|
};
|
||||||
@ -32,3 +32,10 @@ export type TimeRange = { before: number; after: number };
|
|||||||
export type TimelineType = "timeline" | "events";
|
export type TimelineType = "timeline" | "events";
|
||||||
|
|
||||||
export type TimelineScrubMode = "auto" | "drag" | "hover" | "compat";
|
export type TimelineScrubMode = "auto" | "drag" | "hover" | "compat";
|
||||||
|
|
||||||
|
export type Position = {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
timestamp: number;
|
||||||
|
lifecycle_item?: ObjectLifecycleSequence;
|
||||||
|
};
|
||||||
|
47
web/src/utils/lifecycleUtil.ts
Normal file
47
web/src/utils/lifecycleUtil.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import { ObjectLifecycleSequence } from "@/types/timeline";
|
||||||
|
|
||||||
|
export function getLifecycleItemDescription(
|
||||||
|
lifecycleItem: ObjectLifecycleSequence,
|
||||||
|
) {
|
||||||
|
const label = (
|
||||||
|
(Array.isArray(lifecycleItem.data.sub_label)
|
||||||
|
? lifecycleItem.data.sub_label[0]
|
||||||
|
: lifecycleItem.data.sub_label) || lifecycleItem.data.label
|
||||||
|
).replaceAll("_", " ");
|
||||||
|
|
||||||
|
switch (lifecycleItem.class_type) {
|
||||||
|
case "visible":
|
||||||
|
return `${label} detected`;
|
||||||
|
case "entered_zone":
|
||||||
|
return `${label} entered ${lifecycleItem.data.zones
|
||||||
|
.join(" and ")
|
||||||
|
.replaceAll("_", " ")}`;
|
||||||
|
case "active":
|
||||||
|
return `${label} became active`;
|
||||||
|
case "stationary":
|
||||||
|
return `${label} became stationary`;
|
||||||
|
case "attribute": {
|
||||||
|
let title = "";
|
||||||
|
if (
|
||||||
|
lifecycleItem.data.attribute == "face" ||
|
||||||
|
lifecycleItem.data.attribute == "license_plate"
|
||||||
|
) {
|
||||||
|
title = `${lifecycleItem.data.attribute.replaceAll(
|
||||||
|
"_",
|
||||||
|
" ",
|
||||||
|
)} detected for ${label}`;
|
||||||
|
} else {
|
||||||
|
title = `${
|
||||||
|
lifecycleItem.data.label
|
||||||
|
} recognized as ${lifecycleItem.data.attribute.replaceAll("_", " ")}`;
|
||||||
|
}
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
case "gone":
|
||||||
|
return `${label} left`;
|
||||||
|
case "heard":
|
||||||
|
return `${label} heard`;
|
||||||
|
case "external":
|
||||||
|
return `${label} detected`;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user