Reorganize Lifecycle components (#16663)

* reorganize lifecycle components

* clean up
This commit is contained in:
Josh Hawkins 2025-02-18 08:17:51 -06:00 committed by GitHub
parent 4f88a5f2ad
commit 1d3de77f63
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 199 additions and 178 deletions

View File

@ -11,7 +11,7 @@ import {
CarouselPrevious,
} from "@/components/ui/carousel";
import { Button } from "@/components/ui/button";
import { ClassType, ObjectLifecycleSequence } from "@/types/timeline";
import { ObjectLifecycleSequence } from "@/types/timeline";
import Heading from "@/components/ui/heading";
import { ReviewDetailPaneType } from "@/types/review";
import { FrigateConfig } from "@/types/frigateConfig";
@ -52,13 +52,8 @@ import {
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { useNavigate } from "react-router-dom";
type Position = {
x: number;
y: number;
timestamp: number;
lifecycle_item?: ObjectLifecycleSequence;
};
import { ObjectPath } from "./ObjectPath";
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
type ObjectLifecycleProps = {
className?: string;
@ -400,6 +395,8 @@ export default function ObjectLifecycle({
/>
{showZones &&
imgRef.current?.width &&
imgRef.current?.height &&
lifecycleZones?.map((zone) => (
<div
className="absolute inset-0 flex items-center justify-center"
@ -434,29 +431,32 @@ 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>
)}
{pathPoints && pathPoints.length > 0 && (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
width: imgRef.current?.clientWidth,
height: imgRef.current?.clientHeight,
}}
key="path"
>
<svg
viewBox={`0 0 ${imgRef.current?.width} ${imgRef.current?.height}`}
className="absolute inset-0"
{imgRef.current?.width &&
imgRef.current?.height &&
pathPoints &&
pathPoints.length > 0 && (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
width: imgRef.current?.clientWidth,
height: imgRef.current?.clientHeight,
}}
key="path"
>
<ObjectPath
positions={pathPoints} // Use any of the example paths
color={getObjectColor(event.label)}
width={2}
imgRef={imgRef}
onPointClick={handlePathPointClick}
/>
</svg>
</div>
)}
<svg
viewBox={`0 0 ${imgRef.current?.width} ${imgRef.current?.height}`}
className="absolute inset-0"
>
<ObjectPath
positions={pathPoints}
color={getObjectColor(event.label)}
width={2}
imgRef={imgRef}
onPointClick={handlePathPointClick}
/>
</svg>
</div>
)}
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuItem>
@ -755,149 +755,3 @@ export function LifecycleIcon({
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>
);
}

View 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>
);
}

View File

@ -1,4 +1,4 @@
export enum ClassType {
export enum LifecycleClassType {
VISIBLE = "visible",
GONE = "gone",
ENTERED_ZONE = "entered_zone",
@ -22,7 +22,7 @@ export type ObjectLifecycleSequence = {
attribute: string;
zones: string[];
};
class_type: ClassType;
class_type: LifecycleClassType;
source_id: string;
source: string;
};
@ -32,3 +32,10 @@ export type TimeRange = { before: number; after: number };
export type TimelineType = "timeline" | "events";
export type TimelineScrubMode = "auto" | "drag" | "hover" | "compat";
export type Position = {
x: number;
y: number;
timestamp: number;
lifecycle_item?: ObjectLifecycleSequence;
};

View 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`;
}
}