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 && ( -
- 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`; + } +}