2024-09-04 16:46:49 +03:00
|
|
|
import useSWR from "swr";
|
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
|
|
|
import { Event } from "@/types/event";
|
|
|
|
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
2025-02-18 17:17:51 +03:00
|
|
|
import { ObjectLifecycleSequence } from "@/types/timeline";
|
2024-09-04 16:46:49 +03:00
|
|
|
import Heading from "@/components/ui/heading";
|
2024-09-12 17:46:29 +03:00
|
|
|
import { ReviewDetailPaneType } from "@/types/review";
|
2024-09-04 16:46:49 +03:00
|
|
|
import { FrigateConfig } from "@/types/frigateConfig";
|
|
|
|
|
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
|
|
|
|
|
import { getIconForLabel } from "@/utils/iconUtil";
|
|
|
|
|
import {
|
|
|
|
|
LuCircle,
|
|
|
|
|
LuCircleDot,
|
|
|
|
|
LuEar,
|
|
|
|
|
LuFolderX,
|
|
|
|
|
LuPlay,
|
|
|
|
|
LuSettings,
|
|
|
|
|
LuTruck,
|
|
|
|
|
} from "react-icons/lu";
|
|
|
|
|
import { IoMdArrowRoundBack, IoMdExit } from "react-icons/io";
|
|
|
|
|
import {
|
|
|
|
|
MdFaceUnlock,
|
|
|
|
|
MdOutlineLocationOn,
|
|
|
|
|
MdOutlinePictureInPictureAlt,
|
|
|
|
|
} from "react-icons/md";
|
|
|
|
|
import { cn } from "@/lib/utils";
|
|
|
|
|
import { useApiHost } from "@/api";
|
|
|
|
|
import { isDesktop, isIOS, isSafari } from "react-device-detect";
|
|
|
|
|
import ImageLoadingIndicator from "@/components/indicators/ImageLoadingIndicator";
|
|
|
|
|
import {
|
|
|
|
|
Tooltip,
|
|
|
|
|
TooltipContent,
|
|
|
|
|
TooltipTrigger,
|
|
|
|
|
} from "@/components/ui/tooltip";
|
|
|
|
|
import { AnnotationSettingsPane } from "./AnnotationSettingsPane";
|
2024-09-09 17:33:38 +03:00
|
|
|
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
2025-02-11 19:08:28 +03:00
|
|
|
import {
|
|
|
|
|
ContextMenu,
|
|
|
|
|
ContextMenuContent,
|
|
|
|
|
ContextMenuItem,
|
|
|
|
|
ContextMenuTrigger,
|
|
|
|
|
} from "@/components/ui/context-menu";
|
|
|
|
|
import { useNavigate } from "react-router-dom";
|
2025-02-18 17:17:51 +03:00
|
|
|
import { ObjectPath } from "./ObjectPath";
|
|
|
|
|
import { getLifecycleItemDescription } from "@/utils/lifecycleUtil";
|
2025-03-06 19:50:37 +03:00
|
|
|
import { IoPlayCircleOutline } from "react-icons/io5";
|
2025-03-16 18:36:20 +03:00
|
|
|
import { useTranslation } from "react-i18next";
|
2025-10-18 21:19:21 +03:00
|
|
|
import { getTranslatedLabel } from "@/utils/i18n";
|
2025-10-24 20:08:59 +03:00
|
|
|
import { Badge } from "@/components/ui/badge";
|
2025-02-17 19:37:17 +03:00
|
|
|
|
2024-09-04 16:46:49 +03:00
|
|
|
type ObjectLifecycleProps = {
|
2024-09-12 17:46:29 +03:00
|
|
|
className?: string;
|
2024-09-04 16:46:49 +03:00
|
|
|
event: Event;
|
2024-09-12 17:46:29 +03:00
|
|
|
fullscreen?: boolean;
|
2024-09-04 16:46:49 +03:00
|
|
|
setPane: React.Dispatch<React.SetStateAction<ReviewDetailPaneType>>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export default function ObjectLifecycle({
|
2024-09-12 17:46:29 +03:00
|
|
|
className,
|
2024-09-04 16:46:49 +03:00
|
|
|
event,
|
2024-09-12 17:46:29 +03:00
|
|
|
fullscreen = false,
|
2024-09-04 16:46:49 +03:00
|
|
|
setPane,
|
|
|
|
|
}: ObjectLifecycleProps) {
|
2025-03-16 18:36:20 +03:00
|
|
|
const { t } = useTranslation(["views/explore"]);
|
|
|
|
|
|
2024-09-04 16:46:49 +03:00
|
|
|
const { data: eventSequence } = useSWR<ObjectLifecycleSequence[]>([
|
|
|
|
|
"timeline",
|
|
|
|
|
{
|
|
|
|
|
source_id: event.id,
|
|
|
|
|
},
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const { data: config } = useSWR<FrigateConfig>("config");
|
|
|
|
|
const apiHost = useApiHost();
|
2025-02-11 19:08:28 +03:00
|
|
|
const navigate = useNavigate();
|
2024-09-04 16:46:49 +03:00
|
|
|
|
|
|
|
|
const [imgLoaded, setImgLoaded] = useState(false);
|
|
|
|
|
const imgRef = useRef<HTMLImageElement>(null);
|
|
|
|
|
|
|
|
|
|
const [selectedZone, setSelectedZone] = useState("");
|
|
|
|
|
const [lifecycleZones, setLifecycleZones] = useState<string[]>([]);
|
|
|
|
|
const [showControls, setShowControls] = useState(false);
|
|
|
|
|
const [showZones, setShowZones] = useState(true);
|
|
|
|
|
|
2024-10-01 16:01:45 +03:00
|
|
|
const aspectRatio = useMemo(() => {
|
|
|
|
|
if (!config) {
|
|
|
|
|
return 16 / 9;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
config.cameras[event.camera].detect.width /
|
|
|
|
|
config.cameras[event.camera].detect.height
|
|
|
|
|
);
|
|
|
|
|
}, [config, event]);
|
|
|
|
|
|
2024-09-04 16:46:49 +03:00
|
|
|
const getZoneColor = useCallback(
|
|
|
|
|
(zoneName: string) => {
|
|
|
|
|
const zoneColor =
|
2024-09-12 17:46:29 +03:00
|
|
|
config?.cameras?.[event.camera]?.zones?.[zoneName]?.color;
|
2024-09-04 16:46:49 +03:00
|
|
|
if (zoneColor) {
|
|
|
|
|
const reversed = [...zoneColor].reverse();
|
|
|
|
|
return reversed;
|
|
|
|
|
}
|
|
|
|
|
},
|
2024-09-12 17:46:29 +03:00
|
|
|
[config, event],
|
2024-09-04 16:46:49 +03:00
|
|
|
);
|
|
|
|
|
|
2025-02-17 19:37:17 +03:00
|
|
|
const getObjectColor = useCallback(
|
|
|
|
|
(label: string) => {
|
|
|
|
|
const objectColor = config?.model?.colormap[label];
|
|
|
|
|
if (objectColor) {
|
|
|
|
|
const reversed = [...objectColor].reverse();
|
|
|
|
|
return reversed;
|
|
|
|
|
}
|
|
|
|
|
},
|
|
|
|
|
[config],
|
|
|
|
|
);
|
|
|
|
|
|
2024-09-04 16:46:49 +03:00
|
|
|
const getZonePolygon = useCallback(
|
|
|
|
|
(zoneName: string) => {
|
|
|
|
|
if (!imgRef.current || !config) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
const zonePoints =
|
2024-09-12 17:46:29 +03:00
|
|
|
config?.cameras[event.camera].zones[zoneName].coordinates;
|
2024-09-04 16:46:49 +03:00
|
|
|
const imgElement = imgRef.current;
|
|
|
|
|
const imgRect = imgElement.getBoundingClientRect();
|
|
|
|
|
|
|
|
|
|
return zonePoints
|
|
|
|
|
.split(",")
|
2025-02-17 19:37:17 +03:00
|
|
|
.map(Number.parseFloat)
|
2024-09-04 16:46:49 +03:00
|
|
|
.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(",");
|
|
|
|
|
},
|
2024-09-12 17:46:29 +03:00
|
|
|
[config, imgRef, event],
|
2024-09-04 16:46:49 +03:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const [boxStyle, setBoxStyle] = useState<React.CSSProperties | null>(null);
|
2025-10-16 16:00:38 +03:00
|
|
|
const [attributeBoxStyle, setAttributeBoxStyle] =
|
|
|
|
|
useState<React.CSSProperties | null>(null);
|
2024-09-04 16:46:49 +03:00
|
|
|
|
|
|
|
|
const configAnnotationOffset = useMemo(() => {
|
|
|
|
|
if (!config) {
|
|
|
|
|
return 0;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return config.cameras[event.camera]?.detect?.annotation_offset || 0;
|
|
|
|
|
}, [config, event]);
|
|
|
|
|
|
|
|
|
|
const [annotationOffset, setAnnotationOffset] = useState<number>(
|
|
|
|
|
configAnnotationOffset,
|
|
|
|
|
);
|
|
|
|
|
|
2025-02-17 19:37:17 +03:00
|
|
|
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
|
2025-02-18 02:03:51 +03:00
|
|
|
if (
|
|
|
|
|
savedPathPoints.length === 0 ||
|
|
|
|
|
config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config
|
|
|
|
|
)
|
|
|
|
|
return [];
|
2025-02-17 19:37:17 +03:00
|
|
|
return [...savedPathPoints, ...eventSequencePoints].sort(
|
|
|
|
|
(a, b) => a.timestamp - b.timestamp,
|
|
|
|
|
);
|
2025-02-18 02:03:51 +03:00
|
|
|
}, [savedPathPoints, eventSequencePoints, config, event]);
|
2025-02-17 19:37:17 +03:00
|
|
|
|
2024-09-04 16:46:49 +03:00
|
|
|
const [timeIndex, setTimeIndex] = useState(0);
|
|
|
|
|
|
|
|
|
|
const handleSetBox = useCallback(
|
2025-10-16 16:00:38 +03:00
|
|
|
(box: number[], attrBox: number[] | undefined) => {
|
2024-09-04 16:46:49 +03:00
|
|
|
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`,
|
2025-02-17 19:37:17 +03:00
|
|
|
borderColor: `rgb(${getObjectColor(event.label)?.join(",")})`,
|
2024-09-04 16:46:49 +03:00
|
|
|
};
|
|
|
|
|
|
2025-10-16 16:00:38 +03:00
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
|
2024-09-04 16:46:49 +03:00
|
|
|
setBoxStyle(style);
|
|
|
|
|
}
|
|
|
|
|
},
|
2025-02-17 19:37:17 +03:00
|
|
|
[imgRef, event, getObjectColor],
|
2024-09-04 16:46:49 +03:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// 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(() => {
|
|
|
|
|
if (timeIndex) {
|
|
|
|
|
const newSrc = `${apiHost}api/${event.camera}/recordings/${timeIndex + annotationOffset / 1000}/snapshot.jpg?height=500`;
|
|
|
|
|
setSrc(newSrc);
|
|
|
|
|
}
|
|
|
|
|
setImgLoaded(false);
|
|
|
|
|
setHasError(false);
|
|
|
|
|
// we know that these deps are correct
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
}, [timeIndex, annotationOffset]);
|
|
|
|
|
|
|
|
|
|
// carousels
|
|
|
|
|
|
2025-10-18 21:19:21 +03:00
|
|
|
// Selected lifecycle item index; -1 when viewing a path-only point
|
2024-09-04 16:46:49 +03:00
|
|
|
|
2025-02-17 19:37:17 +03:00
|
|
|
const handlePathPointClick = useCallback(
|
|
|
|
|
(index: number) => {
|
2025-10-18 21:19:21 +03:00
|
|
|
if (!eventSequence) return;
|
2025-02-17 19:37:17 +03:00
|
|
|
const sequenceIndex = eventSequence.findIndex(
|
|
|
|
|
(item) => item.timestamp === pathPoints[index].timestamp,
|
|
|
|
|
);
|
|
|
|
|
if (sequenceIndex !== -1) {
|
2025-10-18 21:19:21 +03:00
|
|
|
setTimeIndex(eventSequence[sequenceIndex].timestamp);
|
|
|
|
|
handleSetBox(
|
|
|
|
|
eventSequence[sequenceIndex]?.data.box ?? [],
|
|
|
|
|
eventSequence[sequenceIndex]?.data?.attribute_box,
|
|
|
|
|
);
|
|
|
|
|
setLifecycleZones(eventSequence[sequenceIndex]?.data.zones);
|
2025-03-25 23:08:40 +03:00
|
|
|
} else {
|
|
|
|
|
// click on a normal path point, not a lifecycle point
|
|
|
|
|
setTimeIndex(pathPoints[index].timestamp);
|
2025-10-18 21:19:21 +03:00
|
|
|
setBoxStyle(null);
|
|
|
|
|
setLifecycleZones([]);
|
2025-02-17 19:37:17 +03:00
|
|
|
}
|
|
|
|
|
},
|
2025-10-18 21:19:21 +03:00
|
|
|
[eventSequence, pathPoints, handleSetBox],
|
2025-02-17 19:37:17 +03:00
|
|
|
);
|
|
|
|
|
|
2025-10-18 21:19:21 +03:00
|
|
|
const formattedStart = config
|
|
|
|
|
? formatUnixTimestampToDateTime(event.start_time ?? 0, {
|
|
|
|
|
timezone: config.ui.timezone,
|
|
|
|
|
date_format:
|
|
|
|
|
config.ui.time_format == "24hour"
|
|
|
|
|
? t("time.formattedTimestampHourMinuteSecond.24hour", {
|
|
|
|
|
ns: "common",
|
|
|
|
|
})
|
|
|
|
|
: t("time.formattedTimestampHourMinuteSecond.12hour", {
|
|
|
|
|
ns: "common",
|
|
|
|
|
}),
|
|
|
|
|
time_style: "medium",
|
|
|
|
|
date_style: "medium",
|
|
|
|
|
})
|
|
|
|
|
: "";
|
|
|
|
|
|
|
|
|
|
const formattedEnd = config
|
|
|
|
|
? formatUnixTimestampToDateTime(event.end_time ?? 0, {
|
|
|
|
|
timezone: config.ui.timezone,
|
|
|
|
|
date_format:
|
|
|
|
|
config.ui.time_format == "24hour"
|
|
|
|
|
? t("time.formattedTimestampHourMinuteSecond.24hour", {
|
|
|
|
|
ns: "common",
|
|
|
|
|
})
|
|
|
|
|
: t("time.formattedTimestampHourMinuteSecond.12hour", {
|
|
|
|
|
ns: "common",
|
|
|
|
|
}),
|
|
|
|
|
time_style: "medium",
|
|
|
|
|
date_style: "medium",
|
|
|
|
|
})
|
|
|
|
|
: "";
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!eventSequence || eventSequence.length === 0) return;
|
|
|
|
|
// If timeIndex hasn't been set to a non-zero value, prefer the first lifecycle timestamp
|
|
|
|
|
if (!timeIndex) {
|
|
|
|
|
setTimeIndex(eventSequence[0].timestamp);
|
|
|
|
|
handleSetBox(
|
|
|
|
|
eventSequence[0]?.data.box ?? [],
|
|
|
|
|
eventSequence[0]?.data?.attribute_box,
|
|
|
|
|
);
|
|
|
|
|
setLifecycleZones(eventSequence[0]?.data.zones);
|
|
|
|
|
}
|
|
|
|
|
}, [eventSequence, timeIndex, handleSetBox]);
|
|
|
|
|
|
|
|
|
|
// When timeIndex changes or image finishes loading, sync the box/zones to matching lifecycle, else clear
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!eventSequence || timeIndex == null) return;
|
|
|
|
|
const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex);
|
|
|
|
|
if (idx !== -1) {
|
|
|
|
|
if (imgLoaded) {
|
|
|
|
|
handleSetBox(
|
|
|
|
|
eventSequence[idx]?.data.box ?? [],
|
|
|
|
|
eventSequence[idx]?.data?.attribute_box,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
setLifecycleZones(eventSequence[idx]?.data.zones);
|
|
|
|
|
} else {
|
|
|
|
|
// Non-lifecycle point (e.g., saved path point)
|
|
|
|
|
setBoxStyle(null);
|
|
|
|
|
setLifecycleZones([]);
|
|
|
|
|
}
|
|
|
|
|
}, [timeIndex, imgLoaded, eventSequence, handleSetBox]);
|
|
|
|
|
|
|
|
|
|
const selectedLifecycle = useMemo(() => {
|
|
|
|
|
if (!eventSequence || eventSequence.length === 0) return undefined;
|
|
|
|
|
const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex);
|
|
|
|
|
return idx !== -1 ? eventSequence[idx] : eventSequence[0];
|
|
|
|
|
}, [eventSequence, timeIndex]);
|
|
|
|
|
|
|
|
|
|
const selectedIndex = useMemo(() => {
|
|
|
|
|
if (!eventSequence || eventSequence.length === 0) return 0;
|
|
|
|
|
const idx = eventSequence.findIndex((i) => i.timestamp === timeIndex);
|
|
|
|
|
return idx === -1 ? 0 : idx;
|
|
|
|
|
}, [eventSequence, timeIndex]);
|
|
|
|
|
|
2025-10-24 20:08:59 +03:00
|
|
|
// Calculate how far down the blue line should extend based on timeIndex
|
|
|
|
|
const calculateLineHeight = () => {
|
|
|
|
|
if (!eventSequence || eventSequence.length === 0) return 0;
|
|
|
|
|
|
|
|
|
|
const currentTime = timeIndex ?? 0;
|
|
|
|
|
|
|
|
|
|
// Find which events have been passed
|
|
|
|
|
let lastPassedIndex = -1;
|
|
|
|
|
for (let i = 0; i < eventSequence.length; i++) {
|
|
|
|
|
if (currentTime >= (eventSequence[i].timestamp ?? 0)) {
|
|
|
|
|
lastPassedIndex = i;
|
|
|
|
|
} else {
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// No events passed yet
|
|
|
|
|
if (lastPassedIndex < 0) return 0;
|
|
|
|
|
|
|
|
|
|
// All events passed
|
|
|
|
|
if (lastPassedIndex >= eventSequence.length - 1) return 100;
|
|
|
|
|
|
|
|
|
|
// Calculate percentage based on item position, not time
|
|
|
|
|
// Each item occupies an equal visual space regardless of time gaps
|
|
|
|
|
const itemPercentage = 100 / (eventSequence.length - 1);
|
|
|
|
|
|
|
|
|
|
// Find progress between current and next event for smooth transition
|
|
|
|
|
const currentEvent = eventSequence[lastPassedIndex];
|
|
|
|
|
const nextEvent = eventSequence[lastPassedIndex + 1];
|
|
|
|
|
const currentTimestamp = currentEvent.timestamp ?? 0;
|
|
|
|
|
const nextTimestamp = nextEvent.timestamp ?? 0;
|
|
|
|
|
|
|
|
|
|
// Calculate interpolation between the two events
|
|
|
|
|
const timeBetween = nextTimestamp - currentTimestamp;
|
|
|
|
|
const timeElapsed = currentTime - currentTimestamp;
|
|
|
|
|
const interpolation = timeBetween > 0 ? timeElapsed / timeBetween : 0;
|
|
|
|
|
|
|
|
|
|
// Base position plus interpolated progress to next item
|
|
|
|
|
return Math.min(
|
|
|
|
|
100,
|
|
|
|
|
lastPassedIndex * itemPercentage + interpolation * itemPercentage,
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const blueLineHeight = calculateLineHeight();
|
|
|
|
|
|
2025-10-18 21:19:21 +03:00
|
|
|
if (!config) {
|
2024-09-04 16:46:49 +03:00
|
|
|
return <ActivityIndicator />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
2024-09-12 17:46:29 +03:00
|
|
|
<div className={className}>
|
|
|
|
|
{!fullscreen && (
|
|
|
|
|
<div className={cn("flex items-center gap-2")}>
|
|
|
|
|
<Button
|
2024-09-12 22:39:35 +03:00
|
|
|
className="mb-2 mt-3 flex items-center gap-2.5 rounded-lg md:mt-0"
|
2025-03-16 18:36:20 +03:00
|
|
|
aria-label={t("label.back", { ns: "common" })}
|
2024-09-12 17:46:29 +03:00
|
|
|
size="sm"
|
|
|
|
|
onClick={() => setPane("overview")}
|
|
|
|
|
>
|
|
|
|
|
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
|
2025-03-16 18:36:20 +03:00
|
|
|
{isDesktop && (
|
|
|
|
|
<div className="text-primary">
|
|
|
|
|
{t("button.back", { ns: "common" })}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2024-09-12 17:46:29 +03:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2024-09-04 16:46:49 +03:00
|
|
|
|
2024-10-01 16:01:45 +03:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"relative mx-auto flex max-h-[50dvh] flex-row justify-center",
|
|
|
|
|
)}
|
|
|
|
|
style={{
|
|
|
|
|
aspectRatio: !imgLoaded ? aspectRatio : undefined,
|
|
|
|
|
}}
|
|
|
|
|
>
|
2024-09-04 16:46:49 +03:00
|
|
|
<ImageLoadingIndicator
|
|
|
|
|
className="absolute inset-0"
|
|
|
|
|
imgLoaded={imgLoaded}
|
|
|
|
|
/>
|
|
|
|
|
{hasError && (
|
|
|
|
|
<div className="relative aspect-video">
|
|
|
|
|
<div className="flex flex-col items-center justify-center p-20 text-center">
|
|
|
|
|
<LuFolderX className="size-16" />
|
2025-03-16 18:36:20 +03:00
|
|
|
{t("objectLifecycle.noImageFound")}
|
2024-09-04 16:46:49 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2024-09-12 22:39:35 +03:00
|
|
|
<div
|
|
|
|
|
className={cn(
|
|
|
|
|
"relative inline-block",
|
|
|
|
|
imgLoaded ? "visible" : "invisible",
|
|
|
|
|
)}
|
|
|
|
|
>
|
2025-02-11 19:08:28 +03:00
|
|
|
<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 &&
|
2025-02-18 17:17:51 +03:00
|
|
|
imgRef.current?.width &&
|
|
|
|
|
imgRef.current?.height &&
|
2025-02-11 19:08:28 +03:00
|
|
|
lifecycleZones?.map((zone) => (
|
|
|
|
|
<div
|
|
|
|
|
className="absolute inset-0 flex items-center justify-center"
|
2024-09-04 16:46:49 +03:00
|
|
|
style={{
|
2025-02-11 19:08:28 +03:00
|
|
|
width: imgRef.current?.clientWidth,
|
|
|
|
|
height: imgRef.current?.clientHeight,
|
2024-09-04 16:46:49 +03:00
|
|
|
}}
|
2025-02-11 19:08:28 +03:00
|
|
|
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 && (
|
2025-02-17 19:37:17 +03:00
|
|
|
<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>
|
|
|
|
|
)}
|
2025-10-16 16:00:38 +03:00
|
|
|
{attributeBoxStyle && (
|
|
|
|
|
<div className="absolute border-2" style={attributeBoxStyle} />
|
|
|
|
|
)}
|
2025-02-18 17:17:51 +03:00
|
|
|
{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"
|
2025-02-17 19:37:17 +03:00
|
|
|
>
|
2025-02-18 17:17:51 +03:00
|
|
|
<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>
|
|
|
|
|
)}
|
2025-02-11 19:08:28 +03:00
|
|
|
</ContextMenuTrigger>
|
|
|
|
|
<ContextMenuContent>
|
|
|
|
|
<ContextMenuItem>
|
|
|
|
|
<div
|
|
|
|
|
className="flex w-full cursor-pointer items-center justify-start gap-2 p-2"
|
|
|
|
|
onClick={() =>
|
|
|
|
|
navigate(
|
2025-10-18 21:19:21 +03:00
|
|
|
`/settings?page=masksAndZones&camera=${event.camera}&object_mask=${selectedLifecycle?.data.box}`,
|
2025-02-11 19:08:28 +03:00
|
|
|
)
|
|
|
|
|
}
|
|
|
|
|
>
|
2025-03-16 18:36:20 +03:00
|
|
|
<div className="text-primary">
|
|
|
|
|
{t("objectLifecycle.createObjectMask")}
|
|
|
|
|
</div>
|
2025-02-11 19:08:28 +03:00
|
|
|
</div>
|
|
|
|
|
</ContextMenuItem>
|
|
|
|
|
</ContextMenuContent>
|
|
|
|
|
</ContextMenu>
|
2024-09-04 16:46:49 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="mt-3 flex flex-row items-center justify-between">
|
2025-03-16 18:36:20 +03:00
|
|
|
<Heading as="h4">{t("objectLifecycle.title")}</Heading>
|
2024-09-04 16:46:49 +03:00
|
|
|
|
|
|
|
|
<div className="flex flex-row gap-2">
|
|
|
|
|
<Tooltip>
|
|
|
|
|
<TooltipTrigger asChild>
|
|
|
|
|
<Button
|
|
|
|
|
variant={showControls ? "select" : "default"}
|
|
|
|
|
className="size-7 p-1.5"
|
2025-03-16 18:36:20 +03:00
|
|
|
aria-label={t("objectLifecycle.adjustAnnotationSettings")}
|
2024-09-04 16:46:49 +03:00
|
|
|
>
|
|
|
|
|
<LuSettings
|
|
|
|
|
className="size-5"
|
|
|
|
|
onClick={() => setShowControls(!showControls)}
|
|
|
|
|
/>
|
|
|
|
|
</Button>
|
|
|
|
|
</TooltipTrigger>
|
2024-09-30 16:32:54 +03:00
|
|
|
<TooltipPortal>
|
2025-03-16 18:36:20 +03:00
|
|
|
<TooltipContent>
|
|
|
|
|
{t("objectLifecycle.adjustAnnotationSettings")}
|
|
|
|
|
</TooltipContent>
|
2024-09-30 16:32:54 +03:00
|
|
|
</TooltipPortal>
|
2024-09-04 16:46:49 +03:00
|
|
|
</Tooltip>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-row items-center justify-between">
|
|
|
|
|
<div className="mb-2 text-sm text-muted-foreground">
|
2025-03-16 18:36:20 +03:00
|
|
|
{t("objectLifecycle.scrollViewTips")}
|
2024-09-04 16:46:49 +03:00
|
|
|
</div>
|
|
|
|
|
<div className="min-w-20 text-right text-sm text-muted-foreground">
|
2025-05-15 01:44:06 +03:00
|
|
|
{t("objectLifecycle.count", {
|
2025-10-18 21:19:21 +03:00
|
|
|
first: selectedIndex + 1,
|
|
|
|
|
second: eventSequence?.length ?? 0,
|
2025-05-15 01:44:06 +03:00
|
|
|
})}
|
2024-09-04 16:46:49 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2025-02-18 02:03:51 +03:00
|
|
|
{config?.cameras[event.camera]?.onvif.autotracking.enabled_in_config && (
|
|
|
|
|
<div className="-mt-2 mb-2 text-sm text-danger">
|
2025-03-16 18:36:20 +03:00
|
|
|
{t("objectLifecycle.autoTrackingTips")}
|
2025-02-18 02:03:51 +03:00
|
|
|
</div>
|
|
|
|
|
)}
|
2024-09-04 16:46:49 +03:00
|
|
|
{showControls && (
|
|
|
|
|
<AnnotationSettingsPane
|
|
|
|
|
event={event}
|
|
|
|
|
showZones={showZones}
|
|
|
|
|
setShowZones={setShowZones}
|
|
|
|
|
annotationOffset={annotationOffset}
|
|
|
|
|
setAnnotationOffset={setAnnotationOffset}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
2025-10-18 21:19:21 +03:00
|
|
|
<div className="mt-4">
|
|
|
|
|
<div
|
|
|
|
|
className={cn(
|
2025-10-24 20:08:59 +03:00
|
|
|
"rounded-md bg-secondary p-3 outline outline-[3px] -outline-offset-[2.8px] outline-transparent duration-500",
|
2025-10-18 21:19:21 +03:00
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex w-full items-center justify-between">
|
|
|
|
|
<div
|
|
|
|
|
className="flex items-center gap-2 font-medium"
|
|
|
|
|
onClick={(e) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
setTimeIndex(event.start_time ?? 0);
|
|
|
|
|
}}
|
|
|
|
|
role="button"
|
|
|
|
|
>
|
2025-10-24 20:08:59 +03:00
|
|
|
<div className={cn("ml-1 rounded-full bg-muted-foreground p-2")}>
|
|
|
|
|
{getIconForLabel(
|
|
|
|
|
event.label,
|
|
|
|
|
"size-6 text-primary dark:text-white",
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
2025-10-18 21:19:21 +03:00
|
|
|
<div className="flex items-end gap-2">
|
|
|
|
|
<span>{getTranslatedLabel(event.label)}</span>
|
|
|
|
|
<span className="text-secondary-foreground">
|
|
|
|
|
{formattedStart ?? ""} - {formattedEnd ?? ""}
|
|
|
|
|
</span>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="mt-2">
|
|
|
|
|
{!eventSequence ? (
|
|
|
|
|
<ActivityIndicator className="size-2" size={2} />
|
|
|
|
|
) : eventSequence.length === 0 ? (
|
|
|
|
|
<div className="py-2 text-muted-foreground">
|
|
|
|
|
{t("detail.noObjectDetailData", { ns: "views/events" })}
|
|
|
|
|
</div>
|
|
|
|
|
) : (
|
2025-10-24 20:08:59 +03:00
|
|
|
<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 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">
|
|
|
|
|
{eventSequence.map((item, idx) => {
|
|
|
|
|
const isActive =
|
|
|
|
|
Math.abs((timeIndex ?? 0) - (item.timestamp ?? 0)) <= 0.5;
|
|
|
|
|
const formattedEventTimestamp = config
|
|
|
|
|
? formatUnixTimestampToDateTime(item.timestamp ?? 0, {
|
|
|
|
|
timezone: config.ui.timezone,
|
|
|
|
|
date_format:
|
|
|
|
|
config.ui.time_format == "24hour"
|
|
|
|
|
? t(
|
|
|
|
|
"time.formattedTimestampHourMinuteSecond.24hour",
|
|
|
|
|
{ ns: "common" },
|
|
|
|
|
)
|
|
|
|
|
: t(
|
|
|
|
|
"time.formattedTimestampHourMinuteSecond.12hour",
|
|
|
|
|
{ ns: "common" },
|
|
|
|
|
),
|
|
|
|
|
time_style: "medium",
|
|
|
|
|
date_style: "medium",
|
|
|
|
|
})
|
|
|
|
|
: "";
|
|
|
|
|
|
|
|
|
|
const ratio =
|
|
|
|
|
Array.isArray(item.data.box) && item.data.box.length >= 4
|
|
|
|
|
? (
|
|
|
|
|
aspectRatio *
|
|
|
|
|
(item.data.box[2] / item.data.box[3])
|
|
|
|
|
).toFixed(2)
|
|
|
|
|
: "N/A";
|
|
|
|
|
const areaPx =
|
|
|
|
|
Array.isArray(item.data.box) && item.data.box.length >= 4
|
|
|
|
|
? Math.round(
|
|
|
|
|
(config.cameras[event.camera]?.detect?.width ?? 0) *
|
|
|
|
|
(config.cameras[event.camera]?.detect?.height ??
|
|
|
|
|
0) *
|
|
|
|
|
(item.data.box[2] * item.data.box[3]),
|
|
|
|
|
)
|
|
|
|
|
: undefined;
|
|
|
|
|
const areaPct =
|
|
|
|
|
Array.isArray(item.data.box) && item.data.box.length >= 4
|
|
|
|
|
? (item.data.box[2] * item.data.box[3]).toFixed(4)
|
|
|
|
|
: undefined;
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<LifecycleIconRow
|
|
|
|
|
key={`${item.timestamp}-${item.source_id ?? ""}-${idx}`}
|
|
|
|
|
item={item}
|
|
|
|
|
isActive={isActive}
|
|
|
|
|
formattedEventTimestamp={formattedEventTimestamp}
|
|
|
|
|
ratio={ratio}
|
|
|
|
|
areaPx={areaPx}
|
|
|
|
|
areaPct={areaPct}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
setTimeIndex(item.timestamp ?? 0);
|
|
|
|
|
handleSetBox(
|
|
|
|
|
item.data.box ?? [],
|
|
|
|
|
item.data.attribute_box,
|
|
|
|
|
);
|
|
|
|
|
setLifecycleZones(item.data.zones);
|
|
|
|
|
setSelectedZone("");
|
|
|
|
|
}}
|
|
|
|
|
setSelectedZone={setSelectedZone}
|
|
|
|
|
getZoneColor={getZoneColor}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
2025-10-18 21:19:21 +03:00
|
|
|
</div>
|
2024-09-09 17:33:38 +03:00
|
|
|
)}
|
2025-10-18 21:19:21 +03:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2024-09-04 16:46:49 +03:00
|
|
|
</div>
|
2024-09-12 17:46:29 +03:00
|
|
|
</div>
|
2024-09-04 16:46:49 +03:00
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type GetTimelineIconParams = {
|
|
|
|
|
lifecycleItem: ObjectLifecycleSequence;
|
|
|
|
|
className?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export function LifecycleIcon({
|
|
|
|
|
lifecycleItem,
|
|
|
|
|
className,
|
|
|
|
|
}: GetTimelineIconParams) {
|
|
|
|
|
switch (lifecycleItem.class_type) {
|
|
|
|
|
case "visible":
|
|
|
|
|
return <LuPlay className={cn(className)} />;
|
|
|
|
|
case "gone":
|
|
|
|
|
return <IoMdExit className={cn(className)} />;
|
|
|
|
|
case "active":
|
2025-03-06 19:50:37 +03:00
|
|
|
return <IoPlayCircleOutline className={cn(className)} />;
|
2024-09-04 16:46:49 +03:00
|
|
|
case "stationary":
|
|
|
|
|
return <LuCircle className={cn(className)} />;
|
|
|
|
|
case "entered_zone":
|
|
|
|
|
return <MdOutlineLocationOn className={cn(className)} />;
|
|
|
|
|
case "attribute":
|
|
|
|
|
switch (lifecycleItem.data?.attribute) {
|
|
|
|
|
case "face":
|
|
|
|
|
return <MdFaceUnlock className={cn(className)} />;
|
|
|
|
|
case "license_plate":
|
|
|
|
|
return <MdOutlinePictureInPictureAlt className={cn(className)} />;
|
|
|
|
|
default:
|
|
|
|
|
return <LuTruck className={cn(className)} />;
|
|
|
|
|
}
|
|
|
|
|
case "heard":
|
|
|
|
|
return <LuEar className={cn(className)} />;
|
|
|
|
|
case "external":
|
|
|
|
|
return <LuCircleDot className={cn(className)} />;
|
|
|
|
|
default:
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-10-24 20:08:59 +03:00
|
|
|
|
|
|
|
|
type LifecycleIconRowProps = {
|
|
|
|
|
item: ObjectLifecycleSequence;
|
|
|
|
|
isActive?: boolean;
|
|
|
|
|
formattedEventTimestamp: string;
|
|
|
|
|
ratio: string;
|
|
|
|
|
areaPx?: number;
|
|
|
|
|
areaPct?: string;
|
|
|
|
|
onClick: () => void;
|
|
|
|
|
setSelectedZone: (z: string) => void;
|
|
|
|
|
getZoneColor: (zoneName: string) => number[] | undefined;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function LifecycleIconRow({
|
|
|
|
|
item,
|
|
|
|
|
isActive,
|
|
|
|
|
formattedEventTimestamp,
|
|
|
|
|
ratio,
|
|
|
|
|
areaPx,
|
|
|
|
|
areaPct,
|
|
|
|
|
onClick,
|
|
|
|
|
setSelectedZone,
|
|
|
|
|
getZoneColor,
|
|
|
|
|
}: LifecycleIconRowProps) {
|
|
|
|
|
const { t } = useTranslation(["views/explore"]);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div
|
|
|
|
|
role="button"
|
|
|
|
|
onClick={onClick}
|
|
|
|
|
className={cn(
|
|
|
|
|
"rounded-md p-2 text-sm text-primary-variant",
|
|
|
|
|
isActive && "bg-secondary-highlight font-semibold text-primary",
|
|
|
|
|
!isActive && "duration-500",
|
|
|
|
|
)}
|
|
|
|
|
>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<div className="relative flex size-4 items-center justify-center">
|
|
|
|
|
<LuCircle
|
|
|
|
|
className={cn(
|
|
|
|
|
"relative z-10 ml-[1px] size-2.5 fill-secondary-foreground stroke-none",
|
|
|
|
|
isActive && "fill-selected duration-300",
|
|
|
|
|
)}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className="flex w-full flex-row justify-between">
|
|
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<div>{getLifecycleItemDescription(item)}</div>
|
|
|
|
|
<div className="mt-1 flex flex-wrap items-center gap-2 text-sm text-secondary-foreground md:gap-5">
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<span className="text-primary-variant">
|
|
|
|
|
{t("objectLifecycle.lifecycleItemDesc.header.ratio")}
|
|
|
|
|
</span>
|
|
|
|
|
<span className="font-medium text-primary">{ratio}</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex items-center gap-1">
|
|
|
|
|
<span className="text-primary-variant">
|
|
|
|
|
{t("objectLifecycle.lifecycleItemDesc.header.area")}
|
|
|
|
|
</span>
|
|
|
|
|
{areaPx !== undefined && areaPct !== undefined ? (
|
|
|
|
|
<span className="font-medium text-primary">
|
|
|
|
|
{t("information.pixels", { ns: "common", area: areaPx })} ·{" "}
|
|
|
|
|
{areaPct}%
|
|
|
|
|
</span>
|
|
|
|
|
) : (
|
|
|
|
|
<span>N/A</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{item.data?.zones && item.data.zones.length > 0 && (
|
|
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
{item.data.zones.map((zone, zidx) => {
|
|
|
|
|
const color = getZoneColor(zone)?.join(",") ?? "0,0,0";
|
|
|
|
|
return (
|
|
|
|
|
<Badge
|
|
|
|
|
key={`${zone}-${zidx}`}
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="inline-flex cursor-pointer items-center gap-2"
|
|
|
|
|
onClick={(e: React.MouseEvent) => {
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
setSelectedZone(zone);
|
|
|
|
|
}}
|
|
|
|
|
style={{
|
|
|
|
|
borderColor: `rgba(${color}, 0.6)`,
|
|
|
|
|
background: `rgba(${color}, 0.08)`,
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<span
|
|
|
|
|
className="size-1 rounded-full"
|
|
|
|
|
style={{
|
|
|
|
|
display: "inline-block",
|
|
|
|
|
width: 10,
|
|
|
|
|
height: 10,
|
|
|
|
|
backgroundColor: `rgb(${color})`,
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
<span className="smart-capitalize">
|
|
|
|
|
{zone.replaceAll("_", " ")}
|
|
|
|
|
</span>
|
|
|
|
|
</Badge>
|
|
|
|
|
);
|
|
|
|
|
})}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div className={cn("p-1 text-sm")}>{formattedEventTimestamp}</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|