From 1d3de77f630be7a666b930a703b823195cf29028 Mon Sep 17 00:00:00 2001
From: Josh Hawkins <32435876+hawkeye217@users.noreply.github.com>
Date: Tue, 18 Feb 2025 08:17:51 -0600
Subject: [PATCH] Reorganize Lifecycle components (#16663)
* reorganize lifecycle components
* clean up
---
.../overlay/detail/ObjectLifecycle.tsx | 206 +++---------------
.../components/overlay/detail/ObjectPath.tsx | 113 ++++++++++
web/src/types/timeline.ts | 11 +-
web/src/utils/lifecycleUtil.ts | 47 ++++
4 files changed, 199 insertions(+), 178 deletions(-)
create mode 100644 web/src/components/overlay/detail/ObjectPath.tsx
create mode 100644 web/src/utils/lifecycleUtil.ts
diff --git a/web/src/components/overlay/detail/ObjectLifecycle.tsx b/web/src/components/overlay/detail/ObjectLifecycle.tsx
index de343861e..40ab543c3 100644
--- a/web/src/components/overlay/detail/ObjectLifecycle.tsx
+++ b/web/src/components/overlay/detail/ObjectLifecycle.tsx
@@ -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) => (
)}
- {pathPoints && pathPoints.length > 0 && (
-
-
+ )}
@@ -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;
- onPointClick?: (index: number) => void;
-};
-
-const typeColorMap: Partial> = {
- [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 (
-
-
- {absolutePositions.map((pos, index) => (
-
-
-
- pos.lifecycle_item && onPointClick && onPointClick(index)
- }
- style={{ cursor: pos.lifecycle_item ? "pointer" : "default" }}
- />
-
-
-
- {pos.lifecycle_item
- ? getLifecycleItemDescription(pos.lifecycle_item)
- : "Tracked point"}
-
-
-
- ))}
-
- );
-}
diff --git a/web/src/components/overlay/detail/ObjectPath.tsx b/web/src/components/overlay/detail/ObjectPath.tsx
new file mode 100644
index 000000000..d85750ee7
--- /dev/null
+++ b/web/src/components/overlay/detail/ObjectPath.tsx
@@ -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;
+ onPointClick?: (index: number) => void;
+};
+
+const typeColorMap: Partial<
+ Record
+> = {
+ [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 (
+
+
+ {absolutePositions.map((pos, index) => (
+
+
+
+ pos.lifecycle_item && onPointClick && onPointClick(index)
+ }
+ style={{ cursor: pos.lifecycle_item ? "pointer" : "default" }}
+ />
+
+
+
+ {pos.lifecycle_item
+ ? getLifecycleItemDescription(pos.lifecycle_item)
+ : "Tracked point"}
+
+
+
+ ))}
+
+ );
+}
diff --git a/web/src/types/timeline.ts b/web/src/types/timeline.ts
index 66366c2f0..45a0821ed 100644
--- a/web/src/types/timeline.ts
+++ b/web/src/types/timeline.ts
@@ -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;
+};
diff --git a/web/src/utils/lifecycleUtil.ts b/web/src/utils/lifecycleUtil.ts
new file mode 100644
index 000000000..f59f3eac9
--- /dev/null
+++ b/web/src/utils/lifecycleUtil.ts
@@ -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`;
+ }
+}