use detail stream in tracking details

This commit is contained in:
Josh Hawkins 2025-10-31 07:22:12 -05:00
parent 275ec2b291
commit 5f4de57bc3

View File

@ -12,7 +12,6 @@ import {
LuCircle, LuCircle,
LuCircleDot, LuCircleDot,
LuEar, LuEar,
LuFolderX,
LuPlay, LuPlay,
LuSettings, LuSettings,
LuTruck, LuTruck,
@ -24,9 +23,6 @@ import {
MdOutlinePictureInPictureAlt, MdOutlinePictureInPictureAlt,
} from "react-icons/md"; } from "react-icons/md";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useApiHost } from "@/api";
import { isIOS, isSafari } from "react-device-detect";
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
import { import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
@ -34,12 +30,10 @@ import {
} from "@/components/ui/tooltip"; } from "@/components/ui/tooltip";
import { AnnotationSettingsPane } from "./AnnotationSettingsPane"; import { AnnotationSettingsPane } from "./AnnotationSettingsPane";
import { TooltipPortal } from "@radix-ui/react-tooltip"; import { TooltipPortal } from "@radix-ui/react-tooltip";
import { import HlsVideoPlayer from "@/components/player/HlsVideoPlayer";
ContextMenu, import { baseUrl } from "@/api/baseUrl";
ContextMenuContent, import { REVIEW_PADDING } from "@/types/review";
ContextMenuItem, import { ASPECT_VERTICAL_LAYOUT, ASPECT_WIDE_LAYOUT } from "@/types/record";
ContextMenuTrigger,
} from "@/components/ui/context-menu";
import { import {
DropdownMenu, DropdownMenu,
DropdownMenuTrigger, DropdownMenuTrigger,
@ -48,7 +42,6 @@ import {
DropdownMenuPortal, DropdownMenuPortal,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Link, useNavigate } from "react-router-dom"; import { Link, useNavigate } from "react-router-dom";
import { ObjectPath } from "./ObjectPath";
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil"; import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
import { IoPlayCircleOutline } from "react-icons/io5"; import { IoPlayCircleOutline } from "react-icons/io5";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
@ -57,6 +50,10 @@ import { Badge } from "@/components/ui/badge";
import { HiDotsHorizontal } from "react-icons/hi"; import { HiDotsHorizontal } from "react-icons/hi";
import axios from "axios"; import axios from "axios";
import { toast } from "sonner"; import { toast } from "sonner";
import {
DetailStreamProvider,
useDetailStream,
} from "@/context/detail-stream-context";
type TrackingDetailsProps = { type TrackingDetailsProps = {
className?: string; className?: string;
@ -64,19 +61,39 @@ type TrackingDetailsProps = {
fullscreen?: boolean; fullscreen?: boolean;
showImage?: boolean; showImage?: boolean;
showLifecycle?: boolean; showLifecycle?: boolean;
timeIndex?: number;
setTimeIndex?: (index: number) => void;
}; };
export default function TrackingDetails({ // Wrapper component that provides DetailStreamContext
export default function TrackingDetails(props: TrackingDetailsProps) {
const [currentTime, setCurrentTime] = useState(props.event.start_time ?? 0);
return (
<DetailStreamProvider
isDetailMode={true}
currentTime={currentTime}
camera={props.event.camera}
>
<TrackingDetailsInner {...props} onTimeUpdate={setCurrentTime} />
</DetailStreamProvider>
);
}
// Inner component with access to DetailStreamContext
function TrackingDetailsInner({
className, className,
event, event,
showImage = true, showImage = true,
showLifecycle = false, showLifecycle = false,
timeIndex: propTimeIndex, onTimeUpdate,
setTimeIndex: propSetTimeIndex, }: TrackingDetailsProps & { onTimeUpdate: (time: number) => void }) {
}: TrackingDetailsProps) { const videoRef = useRef<HTMLVideoElement | null>(null);
const { t } = useTranslation(["views/explore"]); const { t } = useTranslation(["views/explore"]);
const {
setSelectedObjectIds,
annotationOffset,
setAnnotationOffset,
currentTime,
} = useDetailStream();
const { data: eventSequence } = useSWR<TrackingDetailsSequence[]>([ const { data: eventSequence } = useSWR<TrackingDetailsSequence[]>([
"timeline", "timeline",
@ -86,16 +103,19 @@ export default function TrackingDetails({
]); ]);
const { data: config } = useSWR<FrigateConfig>("config"); const { data: config } = useSWR<FrigateConfig>("config");
const apiHost = useApiHost();
const navigate = useNavigate();
const [imgLoaded, setImgLoaded] = useState(false); // Calculate effective time (currentTime + annotation offset)
const imgRef = useRef<HTMLImageElement>(null); const effectiveTime = useMemo(
() => currentTime + annotationOffset / 1000,
[currentTime, annotationOffset],
);
const [selectedZone, setSelectedZone] = useState(""); const containerRef = useRef<HTMLDivElement | null>(null);
const [lifecycleZones, setLifecycleZones] = useState<string[]>([]); const [_selectedZone, _setSelectedZone] = useState("");
const [_lifecycleZones, setLifecycleZones] = useState<string[]>([]);
const [showControls, setShowControls] = useState(false); const [showControls, setShowControls] = useState(false);
const [showZones, setShowZones] = useState(true); const [showZones, setShowZones] = useState(true);
const [seekToTimestamp, setSeekToTimestamp] = useState<number | null>(null);
const aspectRatio = useMemo(() => { const aspectRatio = useMemo(() => {
if (!config) { if (!config) {
@ -124,183 +144,20 @@ export default function TrackingDetails({
[config, event], [config, event],
); );
const getObjectColor = useCallback( // Set the selected object ID in the context so ObjectTrackOverlay can display it
(label: string) => {
const objectColor = config?.model?.colormap[label];
if (objectColor) {
const reversed = [...objectColor].reverse();
return reversed;
}
},
[config],
);
const getZonePolygon = useCallback(
(zoneName: string) => {
if (!imgRef.current || !config) {
return;
}
const zonePoints =
config?.cameras[event.camera].zones[zoneName].coordinates;
const imgElement = imgRef.current;
const imgRect = imgElement.getBoundingClientRect();
return zonePoints
.split(",")
.map(Number.parseFloat)
.reduce((acc, value, index) => {
const isXCoordinate = index % 2 === 0;
const coordinate = isXCoordinate
? value * imgRect.width
: value * imgRect.height;
acc.push(coordinate);
return acc;
}, [] as number[])
.join(",");
},
[config, imgRef, event],
);
const [boxStyle, setBoxStyle] = useState<React.CSSProperties | null>(null);
const [attributeBoxStyle, setAttributeBoxStyle] =
useState<React.CSSProperties | null>(null);
const configAnnotationOffset = useMemo(() => {
if (!config) {
return 0;
}
return config.cameras[event.camera]?.detect?.annotation_offset || 0;
}, [config, event]);
const [annotationOffset, setAnnotationOffset] = useState<number>(
configAnnotationOffset,
);
const savedPathPoints = useMemo(() => {
return (
event.data.path_data?.map(([coords, timestamp]: [number[], number]) => ({
x: coords[0],
y: coords[1],
timestamp,
lifecycle_item: undefined,
})) || []
);
}, [event.data.path_data]);
const eventSequencePoints = useMemo(() => {
return (
eventSequence
?.filter((event) => event.data.box !== undefined)
.map((event) => {
const [left, top, width, height] = event.data.box!;
return {
x: left + width / 2, // Center x-coordinate
y: top + height, // Bottom y-coordinate
timestamp: event.timestamp,
lifecycle_item: event,
};
}) || []
);
}, [eventSequence]);
// final object path with timeline points included
const pathPoints = useMemo(() => {
// don't display a path if we don't have any saved path points
if (
savedPathPoints.length === 0 ||
config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config
)
return [];
return [...savedPathPoints, ...eventSequencePoints].sort(
(a, b) => a.timestamp - b.timestamp,
);
}, [savedPathPoints, eventSequencePoints, config, event]);
const [localTimeIndex, setLocalTimeIndex] = useState<number>(0);
const timeIndex =
propTimeIndex !== undefined ? propTimeIndex : localTimeIndex;
const setTimeIndex = propSetTimeIndex || setLocalTimeIndex;
const handleSetBox = useCallback(
(box: number[], attrBox: number[] | undefined) => {
if (imgRef.current && Array.isArray(box) && box.length === 4) {
const imgElement = imgRef.current;
const imgRect = imgElement.getBoundingClientRect();
const style = {
left: `${box[0] * imgRect.width}px`,
top: `${box[1] * imgRect.height}px`,
width: `${box[2] * imgRect.width}px`,
height: `${box[3] * imgRect.height}px`,
borderColor: `rgb(${getObjectColor(event.label)?.join(",")})`,
};
if (attrBox) {
const attrStyle = {
left: `${attrBox[0] * imgRect.width}px`,
top: `${attrBox[1] * imgRect.height}px`,
width: `${attrBox[2] * imgRect.width}px`,
height: `${attrBox[3] * imgRect.height}px`,
borderColor: `rgb(${getObjectColor(event.label)?.join(",")})`,
};
setAttributeBoxStyle(attrStyle);
} else {
setAttributeBoxStyle(null);
}
setBoxStyle(style);
}
},
[imgRef, event, getObjectColor],
);
// image
const [src, setSrc] = useState(
`${apiHost}api/${event.camera}/recordings/${event.start_time + annotationOffset / 1000}/snapshot.jpg?height=500`,
);
const [hasError, setHasError] = useState(false);
useEffect(() => { useEffect(() => {
if (propTimeIndex !== undefined) { setSelectedObjectIds([event.id]);
const newSrc = `${apiHost}api/${event.camera}/recordings/${propTimeIndex + annotationOffset / 1000}/snapshot.jpg?height=500`; }, [event.id, setSelectedObjectIds]);
setSrc(newSrc);
}
setImgLoaded(false);
setHasError(false);
// we know that these deps are correct
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [propTimeIndex, annotationOffset]);
// carousels const handleLifecycleClick = useCallback((item: TrackingDetailsSequence) => {
const timestamp = item.timestamp ?? 0;
setLifecycleZones(item.data.zones);
_setSelectedZone("");
// Selected lifecycle item index; -1 when viewing a path-only point // Set the target timestamp to seek to
console.log("handled, seeking to", timestamp);
const handlePathPointClick = useCallback( setSeekToTimestamp(timestamp);
(index: number) => { }, []);
if (!eventSequence) return;
const sequenceIndex = eventSequence.findIndex(
(item) => item.timestamp === pathPoints[index].timestamp,
);
if (sequenceIndex !== -1) {
setTimeIndex(eventSequence[sequenceIndex].timestamp);
handleSetBox(
eventSequence[sequenceIndex]?.data.box ?? [],
eventSequence[sequenceIndex]?.data?.attribute_box,
);
setLifecycleZones(eventSequence[sequenceIndex]?.data.zones);
} else {
// click on a normal path point, not a lifecycle point
setTimeIndex(pathPoints[index].timestamp);
setBoxStyle(null);
setLifecycleZones([]);
}
},
[eventSequence, pathPoints, handleSetBox, setTimeIndex],
);
const formattedStart = config const formattedStart = config
? formatUnixTimestampToDateTime(event.start_time ?? 0, { ? formatUnixTimestampToDateTime(event.start_time ?? 0, {
@ -336,53 +193,40 @@ export default function TrackingDetails({
useEffect(() => { useEffect(() => {
if (!eventSequence || eventSequence.length === 0) return; if (!eventSequence || eventSequence.length === 0) return;
// If timeIndex hasn't been set to a non-zero value, prefer the first lifecycle timestamp setLifecycleZones(eventSequence[0]?.data.zones);
if (timeIndex == null || timeIndex === 0) { }, [eventSequence]);
setTimeIndex(eventSequence[0].timestamp);
handleSetBox(
eventSequence[0]?.data.box ?? [],
eventSequence[0]?.data?.attribute_box,
);
setLifecycleZones(eventSequence[0]?.data.zones);
}
}, [eventSequence, timeIndex, handleSetBox, setTimeIndex]);
// When timeIndex changes or image finishes loading, sync the box/zones to matching lifecycle, else clear // Handle seeking when seekToTimestamp is set
useEffect(() => { useEffect(() => {
if (!eventSequence || propTimeIndex == null) return; console.log("seeking to", seekToTimestamp, videoRef.current);
const idx = eventSequence.findIndex((i) => i.timestamp === propTimeIndex); if (seekToTimestamp === null || !videoRef.current) return;
if (idx !== -1) {
if (imgLoaded) { const relativeTime =
handleSetBox( seekToTimestamp -
eventSequence[idx]?.data.box ?? [], event.start_time +
eventSequence[idx]?.data?.attribute_box, REVIEW_PADDING +
); annotationOffset / 1000;
} if (relativeTime >= 0) {
setLifecycleZones(eventSequence[idx]?.data.zones); videoRef.current.currentTime = relativeTime;
} else {
// Non-lifecycle point (e.g., saved path point)
setBoxStyle(null);
setLifecycleZones([]);
} }
}, [propTimeIndex, imgLoaded, eventSequence, handleSetBox]); setSeekToTimestamp(null);
}, [seekToTimestamp, event.start_time, annotationOffset]);
const selectedLifecycle = useMemo(() => { // Check if current time is within the event's start/stop range
if (!eventSequence || eventSequence.length === 0) return undefined; const isWithinEventRange =
const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex); effectiveTime !== undefined &&
return idx !== -1 ? eventSequence[idx] : eventSequence[0]; event.start_time !== undefined &&
}, [eventSequence, timeIndex]); event.end_time !== undefined &&
effectiveTime >= event.start_time &&
effectiveTime <= event.end_time;
const selectedIndex = useMemo(() => { // Calculate how far down the blue line should extend based on effectiveTime
if (!eventSequence || eventSequence.length === 0) return 0; const calculateLineHeight = useCallback(() => {
const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex); if (!eventSequence || eventSequence.length === 0 || !isWithinEventRange) {
return idx === -1 ? 0 : idx; return 0;
}, [eventSequence, timeIndex]); }
// Calculate how far down the blue line should extend based on timeIndex const currentTime = effectiveTime ?? 0;
const calculateLineHeight = () => {
if (!eventSequence || eventSequence.length === 0) return 0;
const currentTime = timeIndex ?? 0;
// Find which events have been passed // Find which events have been passed
let lastPassedIndex = -1; let lastPassedIndex = -1;
@ -420,156 +264,83 @@ export default function TrackingDetails({
100, 100,
lastPassedIndex * itemPercentage + interpolation * itemPercentage, lastPassedIndex * itemPercentage + interpolation * itemPercentage,
); );
}; }, [eventSequence, effectiveTime, isWithinEventRange]);
const blueLineHeight = calculateLineHeight(); const blueLineHeight = calculateLineHeight();
const videoSource = useMemo(() => {
const startTime = event.start_time - REVIEW_PADDING;
const endTime = (event.end_time ?? Date.now() / 1000) + REVIEW_PADDING;
return {
playlist: `${baseUrl}vod/${event.camera}/start/${startTime}/end/${endTime}/index.m3u8`,
startPosition: 0,
};
}, [event]);
// Determine camera aspect ratio category
const cameraAspect = useMemo(() => {
if (!aspectRatio) {
return "normal";
} else if (aspectRatio > ASPECT_WIDE_LAYOUT) {
return "wide";
} else if (aspectRatio < ASPECT_VERTICAL_LAYOUT) {
return "tall";
} else {
return "normal";
}
}, [aspectRatio]);
// Container layout classes - no longer needed, handled in return JSX
const handleSeekToTime = useCallback((timestamp: number, _play?: boolean) => {
// Set the target timestamp to seek to
setSeekToTimestamp(timestamp);
}, []);
const handleTimeUpdate = useCallback(
(time: number) => {
// Convert video time to absolute timestamp
const absoluteTime = time - REVIEW_PADDING + event.start_time;
onTimeUpdate(absoluteTime);
},
[event.start_time, onTimeUpdate],
);
if (!config) { if (!config) {
return <ActivityIndicator />; return <ActivityIndicator />;
} }
return ( return (
<div className={className}> <div className={cn("flex size-full flex-col gap-2", className)}>
<span tabIndex={0} className="sr-only" /> <span tabIndex={0} className="sr-only" />
{showImage && ( {showImage && (
<div <div
className={cn( className={cn(
"relative mx-auto flex max-h-[50dvh] flex-row justify-center", "relative flex items-center justify-center",
cameraAspect === "wide"
? "aspect-wide w-full"
: cameraAspect === "tall"
? "aspect-tall max-h-[60vh]"
: "aspect-video w-full",
)} )}
style={{ ref={containerRef}
aspectRatio: !imgLoaded ? aspectRatio : undefined,
}}
> >
<ImageLoadingIndicator <HlsVideoPlayer
className="absolute inset-0" videoRef={videoRef}
imgLoaded={imgLoaded} containerRef={containerRef}
visible={true}
currentSource={videoSource}
hotKeys={false}
supportsFullscreen={false}
fullscreen={false}
frigateControls={false}
onTimeUpdate={handleTimeUpdate}
onSeekToTime={handleSeekToTime}
isDetailMode={true}
camera={event.camera}
currentTimeOverride={currentTime}
/> />
{hasError && (
<div className="relative aspect-video">
<div className="flex flex-col items-center justify-center p-20 text-center">
<LuFolderX className="size-16" />
{t("trackingDetails.noImageFound")}
</div>
</div>
)}
<div
className={cn(
"relative inline-block",
imgLoaded ? "visible" : "invisible",
)}
>
<ContextMenu>
<ContextMenuTrigger>
<img
key={event.id}
ref={imgRef}
className={cn(
"max-h-[50dvh] max-w-full select-none rounded-lg object-contain",
)}
loading={isSafari ? "eager" : "lazy"}
style={
isIOS
? {
WebkitUserSelect: "none",
WebkitTouchCallout: "none",
}
: undefined
}
draggable={false}
src={src}
onLoad={() => setImgLoaded(true)}
onError={() => setHasError(true)}
/>
{showZones &&
imgRef.current?.width &&
imgRef.current?.height &&
lifecycleZones?.map((zone) => (
<div
className="absolute inset-0 flex items-center justify-center"
style={{
width: imgRef.current?.clientWidth,
height: imgRef.current?.clientHeight,
}}
key={zone}
>
<svg
viewBox={`0 0 ${imgRef.current?.width} ${imgRef.current?.height}`}
className="absolute inset-0"
>
<polygon
points={getZonePolygon(zone)}
className="fill-none stroke-2"
style={{
stroke: `rgb(${getZoneColor(zone)?.join(",")})`,
fill:
selectedZone == zone
? `rgba(${getZoneColor(zone)?.join(",")}, 0.5)`
: `rgba(${getZoneColor(zone)?.join(",")}, 0.3)`,
strokeWidth: selectedZone == zone ? 4 : 2,
}}
/>
</svg>
</div>
))}
{boxStyle && (
<div className="absolute border-2" style={boxStyle}>
<div className="absolute bottom-[-3px] left-1/2 h-[5px] w-[5px] -translate-x-1/2 transform bg-yellow-500" />
</div>
)}
{attributeBoxStyle && (
<div
className="absolute border-2"
style={attributeBoxStyle}
/>
)}
{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"
>
<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>
<div
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
onClick={() =>
navigate(
`/settings?page=masksAndZones&camera=${event.camera}&object_mask=${selectedLifecycle?.data.box}`,
)
}
>
<div className="text-primary">
{t("trackingDetails.createObjectMask")}
</div>
</div>
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</div>
</div> </div>
)} )}
@ -600,14 +371,13 @@ export default function TrackingDetails({
</Tooltip> </Tooltip>
</div> </div>
</div> </div>
<div className="flex flex-row items-center justify-between"> <div className="flex flex-row items-center justify-between">
<div className="mb-2 text-sm text-muted-foreground"> <div className="mb-2 text-sm text-muted-foreground">
{t("trackingDetails.scrollViewTips")} {t("trackingDetails.scrollViewTips")}
</div> </div>
<div className="min-w-20 text-right text-sm text-muted-foreground"> <div className="min-w-20 text-right text-sm text-muted-foreground">
{t("trackingDetails.count", { {t("trackingDetails.count", {
first: selectedIndex + 1, first: eventSequence?.length ?? 0,
second: eventSequence?.length ?? 0, second: eventSequence?.length ?? 0,
})} })}
</div> </div>
@ -624,7 +394,14 @@ export default function TrackingDetails({
showZones={showZones} showZones={showZones}
setShowZones={setShowZones} setShowZones={setShowZones}
annotationOffset={annotationOffset} annotationOffset={annotationOffset}
setAnnotationOffset={setAnnotationOffset} setAnnotationOffset={(value) => {
if (typeof value === "function") {
const newValue = value(annotationOffset);
setAnnotationOffset(newValue);
} else {
setAnnotationOffset(value);
}
}}
/> />
)} )}
@ -639,7 +416,7 @@ export default function TrackingDetails({
className="flex items-center gap-2 font-medium" className="flex items-center gap-2 font-medium"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setTimeIndex(event.start_time ?? 0); handleSeekToTime(event.start_time ?? 0);
}} }}
role="button" role="button"
> >
@ -685,15 +462,17 @@ export default function TrackingDetails({
) : ( ) : (
<div className="-pb-2 relative mx-2"> <div className="-pb-2 relative mx-2">
<div className="absolute -top-2 bottom-8 left-4 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" /> <div className="absolute -top-2 bottom-8 left-4 z-0 w-0.5 -translate-x-1/2 bg-secondary-foreground" />
<div {isWithinEventRange && (
className="absolute left-4 top-2 z-[5] max-h-[calc(100%-3rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300" <div
style={{ height: `${blueLineHeight}%` }} className="absolute left-4 top-2 z-[5] max-h-[calc(100%-3rem)] w-0.5 -translate-x-1/2 bg-selected transition-all duration-300"
/> style={{ height: `${blueLineHeight}%` }}
/>
)}
<div className="space-y-2"> <div className="space-y-2">
{eventSequence.map((item, idx) => { {eventSequence.map((item, idx) => {
const isActive = const isActive =
Math.abs( Math.abs(
(propTimeIndex ?? 0) - (item.timestamp ?? 0), (effectiveTime ?? 0) - (item.timestamp ?? 0),
) <= 0.5; ) <= 0.5;
const formattedEventTimestamp = config const formattedEventTimestamp = config
? formatUnixTimestampToDateTime(item.timestamp ?? 0, { ? formatUnixTimestampToDateTime(item.timestamp ?? 0, {
@ -747,16 +526,8 @@ export default function TrackingDetails({
ratio={ratio} ratio={ratio}
areaPx={areaPx} areaPx={areaPx}
areaPct={areaPct} areaPct={areaPct}
onClick={() => { onClick={() => handleLifecycleClick(item)}
setTimeIndex(item.timestamp ?? 0); setSelectedZone={_setSelectedZone}
handleSetBox(
item.data.box ?? [],
item.data.attribute_box,
);
setLifecycleZones(item.data.zones);
setSelectedZone("");
}}
setSelectedZone={setSelectedZone}
getZoneColor={getZoneColor} getZoneColor={getZoneColor}
/> />
); );
@ -784,28 +555,27 @@ export function LifecycleIcon({
}: GetTimelineIconParams) { }: GetTimelineIconParams) {
switch (lifecycleItem.class_type) { switch (lifecycleItem.class_type) {
case "visible": case "visible":
return <LuPlay className={cn(className)} />; return <LuPlay className={cn("size-5", className)} />;
case "gone": case "gone":
return <IoMdExit className={cn(className)} />; return <IoMdExit className={cn("size-5", className)} />;
case "active": case "active":
return <IoPlayCircleOutline className={cn(className)} />; return <LuCircleDot className={cn("size-5", className)} />;
case "stationary": case "stationary":
return <LuCircle className={cn(className)} />; return <LuCircle className={cn("size-5", className)} />;
case "entered_zone": case "entered_zone":
return <MdOutlineLocationOn className={cn(className)} />; return <MdOutlineLocationOn className={cn("size-5", className)} />;
case "attribute": case "attribute":
switch (lifecycleItem.data?.attribute) { return lifecycleItem.data.attribute === "face" ? (
case "face": <MdFaceUnlock className={cn("size-5", className)} />
return <MdFaceUnlock className={cn(className)} />; ) : lifecycleItem.data.attribute === "license_plate" ? (
case "license_plate": <MdOutlinePictureInPictureAlt className={cn("size-5", className)} />
return <MdOutlinePictureInPictureAlt className={cn(className)} />; ) : (
default: <LuTruck className={cn("size-5", className)} />
return <LuTruck className={cn(className)} />; );
}
case "heard": case "heard":
return <LuEar className={cn(className)} />; return <LuEar className={cn("size-5", className)} />;
case "external": case "external":
return <LuCircleDot className={cn(className)} />; return <IoPlayCircleOutline className={cn("size-5", className)} />;
default: default:
return null; return null;
} }