import Heading from "../ui/heading"; import { Separator } from "../ui/separator"; import { Button } from "@/components/ui/button"; import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { useCallback, useEffect, useMemo, useState } from "react"; import { ATTRIBUTE_LABELS, FrigateConfig } from "@/types/frigateConfig"; import useSWR from "swr"; import { zodResolver } from "@hookform/resolvers/zod"; import { useForm } from "react-hook-form"; import { z } from "zod"; import { ZoneFormValuesType, Polygon } from "@/types/canvas"; import { reviewQueries } from "@/utils/zoneEdutUtil"; import { Switch } from "../ui/switch"; import { Label } from "../ui/label"; import PolygonEditControls from "./PolygonEditControls"; import { FaCheckCircle } from "react-icons/fa"; import axios from "axios"; import { Toaster } from "@/components/ui/sonner"; import { toast } from "sonner"; import { flattenPoints, interpolatePoints } from "@/utils/canvasUtil"; import ActivityIndicator from "../indicators/activity-indicator"; type ZoneEditPaneProps = { polygons?: Polygon[]; setPolygons: React.Dispatch>; activePolygonIndex?: number; scaledWidth?: number; scaledHeight?: number; isLoading: boolean; setIsLoading: React.Dispatch>; onSave?: () => void; onCancel?: () => void; }; export default function ZoneEditPane({ polygons, setPolygons, activePolygonIndex, scaledWidth, scaledHeight, isLoading, setIsLoading, onSave, onCancel, }: ZoneEditPaneProps) { const { data: config, mutate: updateConfig } = useSWR("config"); const cameras = useMemo(() => { if (!config) { return []; } return Object.values(config.cameras) .filter((conf) => conf.ui.dashboard && conf.enabled) .sort((aConf, bConf) => aConf.ui.order - bConf.ui.order); }, [config]); const polygon = useMemo(() => { if (polygons && activePolygonIndex !== undefined) { return polygons[activePolygonIndex]; } else { return null; } }, [polygons, activePolygonIndex]); const cameraConfig = useMemo(() => { if (polygon?.camera && config) { return config.cameras[polygon.camera]; } }, [polygon, config]); const formSchema = z.object({ name: z .string() .min(2, { message: "Zone name must be at least 2 characters.", }) .transform((val: string) => val.trim().replace(/\s+/g, "_")) .refine( (value: string) => { return !cameras.map((cam) => cam.name).includes(value); }, { message: "Zone name must not be the name of a camera.", }, ) .refine( (value: string) => { const otherPolygonNames = polygons ?.filter((_, index) => index !== activePolygonIndex) .map((polygon) => polygon.name) || []; return !otherPolygonNames.includes(value); }, { message: "Zone name already exists on this camera.", }, ), inertia: z.coerce .number() .min(1, { message: "Inertia must be above 0.", }) .or(z.literal("")), loitering_time: z.coerce .number() .min(0, { message: "Loitering time must be greater than or equal to 0.", }) .optional() .or(z.literal("")), isFinished: z.boolean().refine(() => polygon?.isFinished === true, { message: "The polygon drawing must be finished before saving.", }), objects: z.array(z.string()).optional(), review_alerts: z.boolean().default(false).optional(), review_detections: z.boolean().default(false).optional(), }); const form = useForm>({ resolver: zodResolver(formSchema), mode: "onChange", defaultValues: { name: polygon?.name ?? "", inertia: polygon?.camera && polygon?.name && config?.cameras[polygon.camera]?.zones[polygon.name]?.inertia, loitering_time: polygon?.camera && polygon?.name && config?.cameras[polygon.camera]?.zones[polygon.name]?.loitering_time, isFinished: polygon?.isFinished ?? false, objects: polygon?.objects ?? [], review_alerts: (polygon?.camera && polygon?.name && config?.cameras[ polygon.camera ]?.review.alerts.required_zones.includes(polygon.name)) || false, review_detections: (polygon?.camera && polygon?.name && config?.cameras[ polygon.camera ]?.review.detections.required_zones.includes(polygon.name)) || false, }, }); const saveToConfig = useCallback( async ( { name: zoneName, inertia, loitering_time, objects: form_objects, review_alerts, review_detections, }: ZoneFormValuesType, // values submitted via the form objects: string[], ) => { if (!scaledWidth || !scaledHeight || !polygon) { return; } let mutatedConfig = config; const renamingZone = zoneName != polygon.name && polygon.name != ""; if (renamingZone) { // rename - delete old zone and replace with new const { alertQueries: renameAlertQueries, detectionQueries: renameDetectionQueries, } = reviewQueries( polygon.name, false, false, polygon.camera, cameraConfig?.review.alerts.required_zones || [], cameraConfig?.review.detections.required_zones || [], ); try { await axios.put( `config/set?cameras.${polygon.camera}.zones.${polygon.name}${renameAlertQueries}${renameDetectionQueries}`, { requires_restart: 0, }, ); // Wait for the config to be updated mutatedConfig = await updateConfig(); } catch (error) { toast.error(`Failed to save config changes.`, { position: "top-center", }); return; } } const coordinates = flattenPoints( interpolatePoints(polygon.points, scaledWidth, scaledHeight, 1, 1), ).join(","); let objectQueries = objects .map( (object) => `&cameras.${polygon?.camera}.zones.${zoneName}.objects=${object}`, ) .join(""); const same_objects = form_objects.length == objects.length && form_objects.every(function (element, index) { return element === objects[index]; }); // deleting objects if (!objectQueries && !same_objects && !renamingZone) { objectQueries = `&cameras.${polygon?.camera}.zones.${zoneName}.objects`; } const { alertQueries, detectionQueries } = reviewQueries( zoneName, review_alerts, review_detections, polygon.camera, mutatedConfig?.cameras[polygon.camera]?.review.alerts.required_zones || [], mutatedConfig?.cameras[polygon.camera]?.review.detections .required_zones || [], ); let inertiaQuery = ""; if (inertia) { inertiaQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.inertia=${inertia}`; } let loiteringTimeQuery = ""; if (loitering_time) { loiteringTimeQuery = `&cameras.${polygon?.camera}.zones.${zoneName}.loitering_time=${loitering_time}`; } axios .put( `config/set?cameras.${polygon?.camera}.zones.${zoneName}.coordinates=${coordinates}${inertiaQuery}${loiteringTimeQuery}${objectQueries}${alertQueries}${detectionQueries}`, { requires_restart: 0 }, ) .then((res) => { if (res.status === 200) { toast.success(`Zone (${zoneName}) has been saved.`, { position: "top-center", }); updateConfig(); } else { toast.error(`Failed to save config changes: ${res.statusText}`, { position: "top-center", }); } }) .catch((error) => { toast.error( `Failed to save config changes: ${error.response.data.message}`, { position: "top-center" }, ); }) .finally(() => { setIsLoading(false); }); }, [ config, updateConfig, polygon, scaledWidth, scaledHeight, setIsLoading, cameraConfig, ], ); function onSubmit(values: z.infer) { if (activePolygonIndex === undefined || !values || !polygons) { return; } setIsLoading(true); saveToConfig( values as ZoneFormValuesType, polygons[activePolygonIndex].objects, ); if (onSave) { onSave(); } } if (!polygon) { return; } return ( <> {polygon.name.length ? "Edit" : "New"} Zone

Zones allow you to define a specific area of the frame so you can determine whether or not an object is within a particular area.

{polygons && activePolygonIndex !== undefined && (
{polygons[activePolygonIndex].points.length}{" "} {polygons[activePolygonIndex].points.length > 1 || polygons[activePolygonIndex].points.length == 0 ? "points" : "point"} {polygons[activePolygonIndex].isFinished && ( )}
)}
Click to draw a polygon on the image.
( Name Name must be at least 2 characters and must not be the name of a camera or another zone. )} /> ( Inertia Specifies how many frames that an object must be in a zone before they are considered in the zone. Default: 3 )} /> ( Loitering Time Sets a minimum amount of time in seconds that the object must be in the zone for it to activate. Default: 0 )} /> Objects List of objects that apply to this zone. { if (activePolygonIndex === undefined || !polygons) { return; } const updatedPolygons = [...polygons]; const activePolygon = updatedPolygons[activePolygonIndex]; updatedPolygons[activePolygonIndex] = { ...activePolygon, objects: objects ?? [], }; setPolygons(updatedPolygons); }} /> (
Alerts When an object enters this zone, ensure it is marked as an alert.
)} /> (
Detections When an object enters this zone, ensure it is marked as a detection.
)} /> ( )} />
); } type ZoneObjectSelectorProps = { camera: string; zoneName: string; selectedLabels: string[]; updateLabelFilter: (labels: string[] | undefined) => void; }; export function ZoneObjectSelector({ camera, zoneName, selectedLabels, updateLabelFilter, }: ZoneObjectSelectorProps) { const { data: config } = useSWR("config"); const cameraConfig = useMemo(() => { if (config && camera) { return config.cameras[camera]; } }, [config, camera]); const allLabels = useMemo(() => { if (!cameraConfig || !config) { return []; } const labels = new Set(); cameraConfig.objects.track.forEach((label) => { if (!ATTRIBUTE_LABELS.includes(label)) { labels.add(label); } }); if (zoneName) { if (cameraConfig.zones[zoneName]) { cameraConfig.zones[zoneName].objects.forEach((label) => { if (!ATTRIBUTE_LABELS.includes(label)) { labels.add(label); } }); } } return [...labels].sort() || []; }, [config, cameraConfig, zoneName]); const [currentLabels, setCurrentLabels] = useState( selectedLabels, ); useEffect(() => { updateLabelFilter(currentLabels); }, [currentLabels, updateLabelFilter]); return ( <>
{ if (isChecked) { setCurrentLabels([]); } }} />
{allLabels.map((item) => (
{ if (isChecked) { const updatedLabels = currentLabels ? [...currentLabels] : []; updatedLabels.push(item); setCurrentLabels(updatedLabels); } else { const updatedLabels = currentLabels ? [...currentLabels] : []; // can not deselect the last item if (updatedLabels.length > 1) { updatedLabels.splice(updatedLabels.indexOf(item), 1); setCurrentLabels(updatedLabels); } } }} />
))}
); }